neohive 6.0.2 → 6.1.0
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/CHANGELOG.md +269 -77
- package/README.md +66 -63
- package/SECURITY.md +8 -6
- package/cli.js +377 -35
- package/conversation-templates/autonomous-feature.json +54 -4
- package/conversation-templates/code-review.json +41 -3
- package/conversation-templates/debug-squad.json +41 -3
- package/conversation-templates/feature-build.json +41 -3
- package/conversation-templates/research-write.json +41 -3
- package/dashboard.html +3954 -921
- package/dashboard.js +1192 -153
- package/design-system.css +708 -0
- package/design-system.html +264 -0
- package/lib/agents.js +20 -6
- package/lib/audit.js +417 -0
- package/lib/codex-neohive-toml.js +34 -0
- package/lib/compact.js +5 -2
- package/lib/config.js +4 -3
- package/lib/file-io.js +3 -3
- package/lib/github-sync.js +291 -0
- package/lib/hooks.js +173 -0
- package/lib/ide-activity.js +121 -0
- package/lib/resolve-server-data-dir.js +96 -0
- package/logo.svg +1 -0
- package/package.json +12 -3
- package/scripts/check-portable-paths.mjs +74 -0
- package/server.js +1986 -857
- package/templates/debate.json +24 -5
- package/templates/managed.json +48 -9
- package/templates/pair.json +22 -3
- package/templates/review.json +26 -5
- package/templates/team.json +38 -8
- package/tools/channels.js +116 -0
- package/tools/governance.js +471 -0
- package/tools/hooks.js +65 -0
- package/tools/knowledge.js +301 -0
- package/tools/messaging.js +321 -0
- package/tools/safety.js +144 -0
- package/tools/system.js +198 -0
- package/tools/tasks.js +446 -0
- package/tools/workflows.js +286 -0
package/server.js
CHANGED
|
@@ -15,13 +15,22 @@ const _log = require('./lib/logger');
|
|
|
15
15
|
const _state = require('./lib/state');
|
|
16
16
|
const _config = require('./lib/config');
|
|
17
17
|
const _fileIo = require('./lib/file-io');
|
|
18
|
+
const { cachedRead, invalidateCache, lockAgentsFile, unlockAgentsFile, lockConfigFile, unlockConfigFile, withFileLock, readJsonl, readJsonlFromOffset, tailReadJsonl, readJsonFile, writeJsonFile, registerFileCacheKey } = _fileIo;
|
|
18
19
|
const _agents = require('./lib/agents');
|
|
19
20
|
const _messaging = require('./lib/messaging');
|
|
21
|
+
const _audit = require('./lib/audit');
|
|
20
22
|
const _compact = require('./lib/compact');
|
|
23
|
+
const { readIdeActivity, applyIdeActivityHint } = require('./lib/ide-activity');
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
const DATA_DIR = _config.DATA_DIR;
|
|
26
|
+
|
|
27
|
+
// Initialize audit logging
|
|
28
|
+
_audit.init(DATA_DIR);
|
|
29
|
+
|
|
30
|
+
const _envLog = process.env.NEOHIVE_LOG_LEVEL;
|
|
31
|
+
const LOG_LEVEL = (_envLog != null && String(_envLog).trim() !== '' ? String(_envLog).trim() : 'warn').toLowerCase();
|
|
24
32
|
const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
|
|
33
|
+
|
|
25
34
|
const log = {
|
|
26
35
|
error: (...args) => { if (LOG_LEVELS[LOG_LEVEL] >= 0) process.stderr.write('[NEOHIVE:ERROR] ' + args.map(String).join(' ') + '\n'); },
|
|
27
36
|
warn: (...args) => { if (LOG_LEVELS[LOG_LEVEL] >= 1) process.stderr.write('[NEOHIVE:WARN] ' + args.map(String).join(' ') + '\n'); },
|
|
@@ -29,8 +38,17 @@ const log = {
|
|
|
29
38
|
debug: (...args) => { if (LOG_LEVELS[LOG_LEVEL] >= 3) process.stderr.write('[NEOHIVE:DEBUG] ' + args.map(String).join(' ') + '\n'); },
|
|
30
39
|
};
|
|
31
40
|
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
const _rawNeohiveEnv = String(process.env.NEOHIVE_DATA_DIR || '');
|
|
42
|
+
if (_rawNeohiveEnv && /\$\{|\$\s*workspaceFolder/i.test(_rawNeohiveEnv)) {
|
|
43
|
+
log.warn('[neohive] NEOHIVE_DATA_DIR looks unexpanded (' + _rawNeohiveEnv.substring(0, 60) + '…). Node will not substitute ${workspaceFolder}. Use an absolute path (re-run npx neohive init --cursor) or set env in Cursor. Effective DATA_DIR=' + DATA_DIR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Auto-migrate from .agent-bridge/ to .neohive/ (v5 → v6 rename)
|
|
47
|
+
const _legacyDir = path.join(path.dirname(DATA_DIR), '.agent-bridge');
|
|
48
|
+
if (!fs.existsSync(DATA_DIR) && fs.existsSync(_legacyDir)) {
|
|
49
|
+
try { fs.renameSync(_legacyDir, DATA_DIR); } catch (e) { log.warn('Legacy migration failed:', e.message); }
|
|
50
|
+
}
|
|
51
|
+
|
|
34
52
|
const MESSAGES_FILE = path.join(DATA_DIR, 'messages.jsonl');
|
|
35
53
|
const HISTORY_FILE = path.join(DATA_DIR, 'history.jsonl');
|
|
36
54
|
const AGENTS_FILE = path.join(DATA_DIR, 'agents.json');
|
|
@@ -46,15 +64,57 @@ const LOCKS_FILE = path.join(DATA_DIR, 'locks.json');
|
|
|
46
64
|
const PROGRESS_FILE = path.join(DATA_DIR, 'progress.json');
|
|
47
65
|
const VOTES_FILE = path.join(DATA_DIR, 'votes.json');
|
|
48
66
|
const REVIEWS_FILE = path.join(DATA_DIR, 'reviews.json');
|
|
67
|
+
const NOTIFICATIONS_FILE = path.join(DATA_DIR, 'notifications.json');
|
|
49
68
|
const DEPS_FILE = path.join(DATA_DIR, 'dependencies.json');
|
|
50
69
|
const REPUTATION_FILE = path.join(DATA_DIR, 'reputation.json');
|
|
51
70
|
const COMPRESSED_FILE = path.join(DATA_DIR, 'compressed.json');
|
|
52
71
|
const RULES_FILE = path.join(DATA_DIR, 'rules.json');
|
|
53
|
-
|
|
72
|
+
const AGENT_CARDS_FILE = path.join(DATA_DIR, 'agent-cards.json');
|
|
73
|
+
const PUSH_REQUESTS_FILE = path.join(DATA_DIR, 'push-requests.json');
|
|
74
|
+
const AUDIT_LOG_FILE = path.join(DATA_DIR, 'audit_log.jsonl');
|
|
75
|
+
|
|
76
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
// SERVER_CONFIG — centralized constants (timeouts, thresholds, limits)
|
|
78
|
+
// Override via environment variables where indicated.
|
|
79
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
const SERVER_CONFIG = {
|
|
81
|
+
// Polling / Heartbeat intervals (ms)
|
|
82
|
+
HEARTBEAT_INTERVAL_MS: 15000, // how often agents write heartbeat files
|
|
83
|
+
POLL_INTERVAL_MS: 2000, // message polling cycle
|
|
84
|
+
AUTONOMOUS_LISTEN_MS: 30000, // max listen timeout in autonomous mode
|
|
85
|
+
CODEX_LISTEN_MS: 90000, // max listen timeout for Codex agents
|
|
86
|
+
|
|
87
|
+
// Agent health thresholds (ms)
|
|
88
|
+
AGENT_STALE_THRESHOLD_MS: 30000, // last_activity age before PID check falls back to stale
|
|
89
|
+
AGENT_CACHE_TTL_MS: 5000, // alive-status cache TTL
|
|
90
|
+
AGENT_UNRESPONSIVE_MS: 120000, // not called listen() in > 2 min => unresponsive
|
|
91
|
+
AGENT_SNAPSHOT_MAX_AGE_MS: 3600000,// snapshot older than 1 hr => force refresh
|
|
92
|
+
|
|
93
|
+
// Message rate limits
|
|
94
|
+
RATE_LIMIT_WINDOW_MS: 30000, // sliding window for per-agent send rate limit
|
|
95
|
+
CHANNEL_RATE_WINDOW_MS: 60000, // sliding window for per-channel rate limit
|
|
96
|
+
BUDGET_RESET_MS: 60000, // unaddressed-send budget resets every 60s
|
|
97
|
+
|
|
98
|
+
// Cache TTLs
|
|
99
|
+
READ_CACHE_DEFAULT_TTL_MS: 2000, // default read cache TTL
|
|
100
|
+
WORD_CACHE_TTL_MS: 30000, // word-split cache TTL for task routing
|
|
101
|
+
|
|
102
|
+
// Wait / Lock timeouts (ms)
|
|
103
|
+
FILE_LOCK_MAX_WAIT_MS: 5000, // max wait to acquire a file lock
|
|
104
|
+
RETENTION_DEFAULT_HOURS: 24, // default message retention period (hours)
|
|
105
|
+
|
|
106
|
+
// Message limits
|
|
107
|
+
HISTORY_LIMIT_DEFAULT: 50, // default history page size
|
|
108
|
+
HISTORY_LIMIT_MAX: 500, // max history page size
|
|
109
|
+
MSG_CONTENT_MAX_CHARS: 10000, // max CLI message text length
|
|
110
|
+
|
|
111
|
+
// MCP tool timeouts (seconds)
|
|
112
|
+
MCP_TOOL_TIMEOUT_S: 300, // default MCP tool timeout used in IDE configs
|
|
113
|
+
};
|
|
54
114
|
|
|
55
|
-
// In-memory state for this process
|
|
56
115
|
let registeredName = null;
|
|
57
116
|
let registeredToken = null; // auth token for re-registration
|
|
117
|
+
let autoReclaimedName = false; // true when registeredName was set by autoReclaimDeadSeat() — overridable by explicit register()
|
|
58
118
|
let lastReadOffset = 0; // byte offset into messages.jsonl for efficient polling
|
|
59
119
|
const channelOffsets = new Map(); // per-channel byte offsets for efficient reads
|
|
60
120
|
let heartbeatInterval = null; // heartbeat timer reference
|
|
@@ -62,22 +122,14 @@ let messageSeq = 0; // monotonic sequence counter for message ordering
|
|
|
62
122
|
let currentBranch = 'main'; // which branch this agent is on
|
|
63
123
|
let lastSentAt = 0; // timestamp of last sent message (for group cooldown)
|
|
64
124
|
let sendsSinceLastListen = 0; // enforced: must listen between sends in group mode
|
|
125
|
+
let consecutiveNonListenCalls = 0; // escalating listen() enforcement counter
|
|
126
|
+
let _isCurrentlyListening = false; // true when agent is in a listen() call
|
|
65
127
|
let sendLimit = 1; // default: 1 send per listen cycle (2 if addressed)
|
|
66
128
|
let unaddressedSends = 0; // response budget: unaddressed sends counter
|
|
67
129
|
let budgetResetTime = Date.now(); // resets every 60s
|
|
68
130
|
let _channelSendTimes = {}; // per-channel rate limit sliding window
|
|
69
131
|
|
|
70
|
-
//
|
|
71
|
-
const _cache = {};
|
|
72
|
-
function cachedRead(key, readFn, ttlMs = 2000) {
|
|
73
|
-
const now = Date.now();
|
|
74
|
-
const entry = _cache[key];
|
|
75
|
-
if (entry && now - entry.ts < ttlMs) return entry.val;
|
|
76
|
-
const val = readFn();
|
|
77
|
-
_cache[key] = { val, ts: now };
|
|
78
|
-
return val;
|
|
79
|
-
}
|
|
80
|
-
function invalidateCache(key) { delete _cache[key]; }
|
|
132
|
+
// cachedRead, invalidateCache imported from lib/file-io.js
|
|
81
133
|
|
|
82
134
|
// --- Group conversation mode ---
|
|
83
135
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
@@ -87,20 +139,7 @@ function getConfig() {
|
|
|
87
139
|
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
|
|
88
140
|
}
|
|
89
141
|
|
|
90
|
-
//
|
|
91
|
-
const CONFIG_LOCK = CONFIG_FILE + '.lock';
|
|
92
|
-
function lockConfigFile() {
|
|
93
|
-
const maxWait = 5000; const start = Date.now();
|
|
94
|
-
while (Date.now() - start < maxWait) {
|
|
95
|
-
try { fs.writeFileSync(CONFIG_LOCK, String(process.pid), { flag: 'wx' }); return true; }
|
|
96
|
-
catch { /* lock exists, wait */ }
|
|
97
|
-
const wait = Date.now(); while (Date.now() - wait < 50) {} // busy-wait 50ms
|
|
98
|
-
}
|
|
99
|
-
try { fs.unlinkSync(CONFIG_LOCK); } catch {}
|
|
100
|
-
try { fs.writeFileSync(CONFIG_LOCK, String(process.pid), { flag: 'wx' }); return true; } catch {}
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
function unlockConfigFile() { try { fs.unlinkSync(CONFIG_LOCK); } catch {} }
|
|
142
|
+
// lockConfigFile, unlockConfigFile imported from lib/file-io.js
|
|
104
143
|
|
|
105
144
|
function saveConfig(config) {
|
|
106
145
|
ensureDataDir();
|
|
@@ -242,7 +281,7 @@ function migrateIfNeeded() {
|
|
|
242
281
|
if (fs.existsSync(DATA_VERSION_FILE)) {
|
|
243
282
|
dataVersion = parseInt(fs.readFileSync(DATA_VERSION_FILE, 'utf8').trim()) || 0;
|
|
244
283
|
}
|
|
245
|
-
} catch {}
|
|
284
|
+
} catch (e) { log.debug("data version read failed:", e.message); }
|
|
246
285
|
if (dataVersion >= CURRENT_DATA_VERSION) return;
|
|
247
286
|
|
|
248
287
|
// Run migrations in order
|
|
@@ -251,10 +290,10 @@ function migrateIfNeeded() {
|
|
|
251
290
|
// if (dataVersion < 2) { /* migrate v1 → v2 */ }
|
|
252
291
|
|
|
253
292
|
// Stamp current version
|
|
254
|
-
try { fs.writeFileSync(DATA_VERSION_FILE, String(CURRENT_DATA_VERSION)); } catch {}
|
|
293
|
+
try { fs.writeFileSync(DATA_VERSION_FILE, String(CURRENT_DATA_VERSION)); } catch (e) { log.warn('Failed to write data version:', e.message); }
|
|
255
294
|
}
|
|
256
295
|
|
|
257
|
-
const RESERVED_NAMES = ['__system__', '__all__', '__open__', '__close__', 'system', 'dashboard', 'Dashboard'];
|
|
296
|
+
const RESERVED_NAMES = ['__system__', '__all__', '__open__', '__close__', '__user__', 'system', 'dashboard', 'Dashboard'];
|
|
258
297
|
|
|
259
298
|
function sanitizeName(name) {
|
|
260
299
|
if (typeof name !== 'string' || !/^[a-zA-Z0-9_-]{1,20}$/.test(name)) {
|
|
@@ -307,102 +346,12 @@ function trimConsumedIds(agentName, ids) {
|
|
|
307
346
|
for (const id of ids) {
|
|
308
347
|
if (!currentIds.has(id)) ids.delete(id);
|
|
309
348
|
}
|
|
310
|
-
} catch {}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function readJsonl(file) {
|
|
314
|
-
if (!fs.existsSync(file)) return [];
|
|
315
|
-
const content = fs.readFileSync(file, 'utf8').trim();
|
|
316
|
-
if (!content) return [];
|
|
317
|
-
return content.split(/\r?\n/).map(line => {
|
|
318
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
319
|
-
}).filter(Boolean);
|
|
349
|
+
} catch (e) { log.debug("consumed ID trim failed:", e.message); }
|
|
320
350
|
}
|
|
321
351
|
|
|
322
|
-
//
|
|
323
|
-
// Returns { messages, newOffset } — caller tracks offset between calls
|
|
324
|
-
function readJsonlFromOffset(file, offset) {
|
|
325
|
-
if (!fs.existsSync(file)) return { messages: [], newOffset: 0 };
|
|
326
|
-
const stat = fs.statSync(file);
|
|
327
|
-
if (stat.size <= offset) return { messages: [], newOffset: offset };
|
|
328
|
-
const fd = fs.openSync(file, 'r');
|
|
329
|
-
const buf = Buffer.alloc(stat.size - offset);
|
|
330
|
-
fs.readSync(fd, buf, 0, buf.length, offset);
|
|
331
|
-
fs.closeSync(fd);
|
|
332
|
-
const content = buf.toString('utf8').trim();
|
|
333
|
-
if (!content) return { messages: [], newOffset: stat.size };
|
|
334
|
-
const messages = content.split(/\r?\n/).map(line => {
|
|
335
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
336
|
-
}).filter(Boolean);
|
|
337
|
-
return { messages, newOffset: stat.size };
|
|
338
|
-
}
|
|
352
|
+
// readJsonl, readJsonlFromOffset, tailReadJsonl imported from lib/file-io.js
|
|
339
353
|
|
|
340
|
-
//
|
|
341
|
-
// Seeks near end of file instead of parsing entire file — O(N) instead of O(all)
|
|
342
|
-
function tailReadJsonl(file, lineCount = 100) {
|
|
343
|
-
if (!fs.existsSync(file)) return [];
|
|
344
|
-
const stat = fs.statSync(file);
|
|
345
|
-
if (stat.size === 0) return [];
|
|
346
|
-
// Estimate ~300 bytes per line, read enough from the end
|
|
347
|
-
const readSize = Math.min(stat.size, lineCount * 300);
|
|
348
|
-
const offset = Math.max(0, stat.size - readSize);
|
|
349
|
-
const fd = fs.openSync(file, 'r');
|
|
350
|
-
const buf = Buffer.alloc(readSize);
|
|
351
|
-
fs.readSync(fd, buf, 0, readSize, offset);
|
|
352
|
-
fs.closeSync(fd);
|
|
353
|
-
const content = buf.toString('utf8');
|
|
354
|
-
const lines = content.split(/\r?\n/).filter(l => l.trim());
|
|
355
|
-
// If we started mid-file, first line may be partial — skip it
|
|
356
|
-
if (offset > 0 && lines.length > 0) lines.shift();
|
|
357
|
-
const messages = lines.map(line => {
|
|
358
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
359
|
-
}).filter(Boolean);
|
|
360
|
-
return messages.slice(-lineCount);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// File-based lock for agents.json (prevents registration race conditions)
|
|
364
|
-
const AGENTS_LOCK = AGENTS_FILE + '.lock';
|
|
365
|
-
function lockAgentsFile() {
|
|
366
|
-
const maxWait = 5000; const start = Date.now();
|
|
367
|
-
let backoff = 1; // exponential backoff: 1ms → 2ms → 4ms → ... → 500ms max
|
|
368
|
-
while (Date.now() - start < maxWait) {
|
|
369
|
-
try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; }
|
|
370
|
-
catch { /* lock exists, wait with exponential backoff */ }
|
|
371
|
-
const wait = Date.now(); while (Date.now() - wait < backoff) {}
|
|
372
|
-
backoff = Math.min(backoff * 2, 500);
|
|
373
|
-
}
|
|
374
|
-
// Force-break stale lock after timeout
|
|
375
|
-
try { fs.unlinkSync(AGENTS_LOCK); } catch {}
|
|
376
|
-
try { fs.writeFileSync(AGENTS_LOCK, String(process.pid), { flag: 'wx' }); return true; } catch {}
|
|
377
|
-
return false;
|
|
378
|
-
}
|
|
379
|
-
function unlockAgentsFile() { try { fs.unlinkSync(AGENTS_LOCK); } catch {} }
|
|
380
|
-
|
|
381
|
-
// Generic file lock for any JSON file (tasks, workflows, channels, etc.)
|
|
382
|
-
function withFileLock(filePath, fn) {
|
|
383
|
-
const lockPath = filePath + '.lock';
|
|
384
|
-
const maxWait = 5000; const start = Date.now();
|
|
385
|
-
let backoff = 1;
|
|
386
|
-
while (Date.now() - start < maxWait) {
|
|
387
|
-
try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); break; }
|
|
388
|
-
catch { /* lock exists, wait with exponential backoff */ }
|
|
389
|
-
const wait = Date.now(); while (Date.now() - wait < backoff) {}
|
|
390
|
-
backoff = Math.min(backoff * 2, 500);
|
|
391
|
-
if (Date.now() - start >= maxWait) {
|
|
392
|
-
// Force-break stale lock — only if holding PID is dead
|
|
393
|
-
try {
|
|
394
|
-
const lockPid = parseInt(fs.readFileSync(lockPath, 'utf8').trim(), 10);
|
|
395
|
-
if (lockPid && lockPid !== process.pid) {
|
|
396
|
-
try { process.kill(lockPid, 0); /* PID alive — skip, don't corrupt */ return null; } catch { /* PID dead — safe to break */ }
|
|
397
|
-
}
|
|
398
|
-
} catch {}
|
|
399
|
-
try { fs.unlinkSync(lockPath); } catch {}
|
|
400
|
-
try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); } catch { return fn(); }
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
try { return fn(); } finally { try { fs.unlinkSync(lockPath); } catch {} }
|
|
405
|
-
}
|
|
354
|
+
// lockAgentsFile, unlockAgentsFile, withFileLock imported from lib/file-io.js
|
|
406
355
|
|
|
407
356
|
function getAgents() {
|
|
408
357
|
return cachedRead('agents', () => {
|
|
@@ -418,21 +367,22 @@ function getAgents() {
|
|
|
418
367
|
try {
|
|
419
368
|
const hb = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'));
|
|
420
369
|
if (hb.last_activity) agents[name].last_activity = hb.last_activity;
|
|
370
|
+
if (hb.last_listen_call) agents[name].last_listen_call = hb.last_listen_call;
|
|
421
371
|
if (hb.pid) agents[name].pid = hb.pid;
|
|
422
|
-
} catch {}
|
|
372
|
+
} catch (e) { log.debug("heartbeat merge failed:", e.message); }
|
|
423
373
|
}
|
|
424
374
|
}
|
|
425
|
-
} catch {}
|
|
375
|
+
} catch (e) { log.debug("heartbeat scan failed:", e.message); }
|
|
426
376
|
return agents;
|
|
427
377
|
}, 1500);
|
|
428
378
|
}
|
|
429
379
|
|
|
430
380
|
function saveAgents(agents) {
|
|
431
|
-
// Safe write: serialize first, then write complete string
|
|
432
|
-
// This minimizes the window where the file could be truncated
|
|
433
381
|
const data = JSON.stringify(agents);
|
|
434
382
|
if (data && data.length > 2) {
|
|
435
383
|
fs.writeFileSync(AGENTS_FILE, data);
|
|
384
|
+
} else {
|
|
385
|
+
log.debug('[neohive/agents.json] skipped write (empty {}): ' + AGENTS_FILE);
|
|
436
386
|
}
|
|
437
387
|
invalidateCache('agents');
|
|
438
388
|
}
|
|
@@ -440,14 +390,57 @@ function saveAgents(agents) {
|
|
|
440
390
|
// --- Per-agent heartbeat files (scale fix: eliminates agents.json write contention at 100+ agents) ---
|
|
441
391
|
function heartbeatFile(name) { return path.join(DATA_DIR, `heartbeat-${name}.json`); }
|
|
442
392
|
|
|
443
|
-
|
|
393
|
+
let _lastStdinActivity = null;
|
|
394
|
+
|
|
395
|
+
function touchHeartbeat(name, isListenCall = false) {
|
|
444
396
|
if (!name) return;
|
|
445
397
|
try {
|
|
446
|
-
|
|
447
|
-
|
|
398
|
+
const now = new Date().toISOString();
|
|
399
|
+
const target = heartbeatFile(name);
|
|
400
|
+
|
|
401
|
+
// Preserve existing last_listen_call so periodic heartbeats don't erase it
|
|
402
|
+
let prevLastListenCall = null;
|
|
403
|
+
try {
|
|
404
|
+
if (fs.existsSync(target)) {
|
|
405
|
+
const prev = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
406
|
+
if (prev.last_listen_call) prevLastListenCall = prev.last_listen_call;
|
|
407
|
+
}
|
|
408
|
+
} catch (_) { /* ignore read errors */ }
|
|
409
|
+
|
|
410
|
+
const payload = {
|
|
411
|
+
last_activity: now,
|
|
448
412
|
pid: process.pid,
|
|
449
|
-
|
|
450
|
-
|
|
413
|
+
ppid: process.ppid,
|
|
414
|
+
};
|
|
415
|
+
if (isListenCall) {
|
|
416
|
+
payload.last_listen_call = now;
|
|
417
|
+
} else if (prevLastListenCall) {
|
|
418
|
+
payload.last_listen_call = prevLastListenCall;
|
|
419
|
+
}
|
|
420
|
+
if (_lastStdinActivity) payload.last_stdin_activity = _lastStdinActivity;
|
|
421
|
+
if (process.env.CLAUDE_SESSION_ID) payload.claude_session_id = process.env.CLAUDE_SESSION_ID;
|
|
422
|
+
const tmp = target + '.tmp';
|
|
423
|
+
fs.writeFileSync(tmp, JSON.stringify(payload));
|
|
424
|
+
fs.renameSync(tmp, target);
|
|
425
|
+
} catch (e) { log.debug("heartbeat write failed:", e.message); }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Passive stdin activity tracker.
|
|
430
|
+
* Listens for data on process.stdin and timestamps it into the heartbeat file.
|
|
431
|
+
* Throttled: writes at most once per 2s to avoid disk thrash.
|
|
432
|
+
*/
|
|
433
|
+
let _stdinThrottleTimer = null;
|
|
434
|
+
function startStdinActivityTracker() {
|
|
435
|
+
if (!process.stdin || !process.stdin.readable) return;
|
|
436
|
+
process.stdin.on('data', () => {
|
|
437
|
+
_lastStdinActivity = new Date().toISOString();
|
|
438
|
+
if (_stdinThrottleTimer || !registeredName) return;
|
|
439
|
+
_stdinThrottleTimer = setTimeout(() => {
|
|
440
|
+
_stdinThrottleTimer = null;
|
|
441
|
+
if (registeredName) touchHeartbeat(registeredName);
|
|
442
|
+
}, 2000);
|
|
443
|
+
});
|
|
451
444
|
}
|
|
452
445
|
|
|
453
446
|
|
|
@@ -466,10 +459,10 @@ function isPidAlive(pid, lastActivity) {
|
|
|
466
459
|
// Cache with 5s TTL — PID status doesn't change faster than heartbeats
|
|
467
460
|
const cacheKey = `${pid}_${lastActivity}`;
|
|
468
461
|
const cached = _pidAliveCache[cacheKey];
|
|
469
|
-
if (cached && Date.now() - cached.ts <
|
|
462
|
+
if (cached && Date.now() - cached.ts < SERVER_CONFIG.AGENT_CACHE_TTL_MS) return cached.alive;
|
|
470
463
|
|
|
471
|
-
//
|
|
472
|
-
const STALE_THRESHOLD =
|
|
464
|
+
// 30s stale threshold — 3x the 10s heartbeat interval, catches dead agents faster
|
|
465
|
+
const STALE_THRESHOLD = SERVER_CONFIG.AGENT_STALE_THRESHOLD_MS;
|
|
473
466
|
let alive = false;
|
|
474
467
|
|
|
475
468
|
// PRIORITY 1: Trust heartbeat freshness over PID status
|
|
@@ -496,7 +489,7 @@ function isPidAlive(pid, lastActivity) {
|
|
|
496
489
|
// Evict old entries (keep cache small)
|
|
497
490
|
const keys = Object.keys(_pidAliveCache);
|
|
498
491
|
if (keys.length > 200) {
|
|
499
|
-
const cutoff = Date.now() -
|
|
492
|
+
const cutoff = Date.now() - SERVER_CONFIG.POLL_INTERVAL_MS * 5;
|
|
500
493
|
for (const k of keys) { if (_pidAliveCache[k].ts < cutoff) delete _pidAliveCache[k]; }
|
|
501
494
|
}
|
|
502
495
|
return alive;
|
|
@@ -588,6 +581,21 @@ function buildMessageResponse(msg, consumedIds) {
|
|
|
588
581
|
}
|
|
589
582
|
} catch (e) { log.debug('total message estimate failed:', e.message); }
|
|
590
583
|
|
|
584
|
+
// Task nudge: remind agent of their outstanding tasks
|
|
585
|
+
let taskReminder;
|
|
586
|
+
try {
|
|
587
|
+
const myTasks = getTasks().filter(t => t.assignee === registeredName && (t.status === 'pending' || t.status === 'in_progress'));
|
|
588
|
+
if (myTasks.length > 0) {
|
|
589
|
+
taskReminder = { pending: myTasks.filter(t => t.status === 'pending').length, in_progress: myTasks.filter(t => t.status === 'in_progress').length, tasks: myTasks.map(t => ({ id: t.id, title: t.title, status: t.status })) };
|
|
590
|
+
}
|
|
591
|
+
} catch (e) { log.debug('task reminder in listen failed:', e.message); }
|
|
592
|
+
|
|
593
|
+
// Append report-back protocol reminder to all non-system messages
|
|
594
|
+
const isSystemMsg = msg.from === '__system__' || msg.system === true;
|
|
595
|
+
const reportBackReminder = isSystemMsg
|
|
596
|
+
? undefined
|
|
597
|
+
: 'When done: send_message() with (1) what you did (2) files changed (3) findings (4) blockers. Then call listen().';
|
|
598
|
+
|
|
591
599
|
return {
|
|
592
600
|
success: true,
|
|
593
601
|
message: {
|
|
@@ -595,11 +603,15 @@ function buildMessageResponse(msg, consumedIds) {
|
|
|
595
603
|
from: msg.from,
|
|
596
604
|
content: msg.content,
|
|
597
605
|
timestamp: msg.timestamp,
|
|
606
|
+
priority: classifyPriority(msg),
|
|
598
607
|
...(msg.reply_to && { reply_to: msg.reply_to }),
|
|
599
608
|
...(msg.thread_id && { thread_id: msg.thread_id }),
|
|
609
|
+
...(reportBackReminder && { _protocol: reportBackReminder }),
|
|
600
610
|
},
|
|
601
611
|
pending_count: pendingCount,
|
|
602
612
|
agents_online: agentsOnline,
|
|
613
|
+
coordinator_mode: getConfig().coordinator_mode || 'responsive',
|
|
614
|
+
...(taskReminder && { task_reminder: taskReminder }),
|
|
603
615
|
};
|
|
604
616
|
}
|
|
605
617
|
|
|
@@ -616,9 +628,11 @@ function autoCompact() {
|
|
|
616
628
|
|
|
617
629
|
const messages = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
618
630
|
|
|
619
|
-
// Collect consumed IDs — for __group__ messages,
|
|
631
|
+
// Collect consumed IDs — for __group__ messages, check ALL registered agents (alive + dead)
|
|
632
|
+
// This prevents message loss when agents reconnect after a crash
|
|
620
633
|
const agents = getAgents();
|
|
621
|
-
const
|
|
634
|
+
const allAgentNames = Object.keys(agents);
|
|
635
|
+
const retentionMs = (parseInt(process.env.NEOHIVE_RETENTION_HOURS) || SERVER_CONFIG.RETENTION_DEFAULT_HOURS) * 3600000;
|
|
622
636
|
const allConsumed = new Set();
|
|
623
637
|
const perAgentConsumed = {};
|
|
624
638
|
if (fs.existsSync(DATA_DIR)) {
|
|
@@ -629,18 +643,24 @@ function autoCompact() {
|
|
|
629
643
|
const ids = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'));
|
|
630
644
|
perAgentConsumed[agentName] = new Set(ids);
|
|
631
645
|
ids.forEach(id => allConsumed.add(id));
|
|
632
|
-
} catch {}
|
|
646
|
+
} catch (e) { log.debug("consumed ID read failed:", e.message); }
|
|
633
647
|
}
|
|
634
648
|
}
|
|
635
649
|
}
|
|
636
650
|
|
|
637
651
|
// Keep messages that are NOT fully consumed
|
|
638
|
-
// For __group__ messages: consumed when ALL
|
|
652
|
+
// For __group__ messages: consumed when ALL registered agents consumed OR message exceeds retention period
|
|
639
653
|
// For direct messages: consumed when the recipient has consumed it
|
|
654
|
+
const now = Date.now();
|
|
640
655
|
const active = messages.filter(m => {
|
|
641
656
|
if (m.to === '__group__') {
|
|
642
|
-
//
|
|
643
|
-
|
|
657
|
+
// Time-based retention: critical messages get 2x retention
|
|
658
|
+
const msgTime = new Date(m.timestamp).getTime();
|
|
659
|
+
const msgPriority = classifyPriority(m);
|
|
660
|
+
const effectiveRetention = msgPriority === 'critical' ? retentionMs * 2 : retentionMs;
|
|
661
|
+
if (msgTime < Date.now() - effectiveRetention) return false;
|
|
662
|
+
// Check ALL registered agents (alive + dead) to prevent loss on reconnect
|
|
663
|
+
return !allAgentNames.every(n => n === m.from || (perAgentConsumed[n] && perAgentConsumed[n].has(m.id)));
|
|
644
664
|
}
|
|
645
665
|
// Direct: standard check
|
|
646
666
|
if (!allConsumed.has(m.id)) return true;
|
|
@@ -657,9 +677,23 @@ function autoCompact() {
|
|
|
657
677
|
}
|
|
658
678
|
|
|
659
679
|
// Rewrite messages.jsonl atomically — write to temp file then rename
|
|
680
|
+
// Capture pre-compaction size to detect messages appended during compaction
|
|
681
|
+
const preCompactSize = Buffer.byteLength(content, 'utf8') + 1; // +1 for trailing newline trimmed earlier
|
|
660
682
|
const newContent = active.map(m => JSON.stringify(m)).join('\n') + (active.length ? '\n' : '');
|
|
661
683
|
const tmpFile = msgFile + '.tmp';
|
|
662
684
|
fs.writeFileSync(tmpFile, newContent);
|
|
685
|
+
// Check for messages appended after our initial read
|
|
686
|
+
let lateMessages = '';
|
|
687
|
+
try {
|
|
688
|
+
const currentSize = fs.statSync(msgFile).size;
|
|
689
|
+
if (currentSize > preCompactSize) {
|
|
690
|
+
const fd = fs.openSync(msgFile, 'r');
|
|
691
|
+
const lateBuf = Buffer.alloc(currentSize - preCompactSize);
|
|
692
|
+
fs.readSync(fd, lateBuf, 0, lateBuf.length, preCompactSize);
|
|
693
|
+
fs.closeSync(fd);
|
|
694
|
+
lateMessages = lateBuf.toString('utf8');
|
|
695
|
+
}
|
|
696
|
+
} catch (e) { log.debug('late message check during compaction:', e.message); }
|
|
663
697
|
try {
|
|
664
698
|
fs.renameSync(tmpFile, msgFile);
|
|
665
699
|
} catch {
|
|
@@ -668,7 +702,12 @@ function autoCompact() {
|
|
|
668
702
|
try { fs.unlinkSync(tmpFile); } catch {}
|
|
669
703
|
return;
|
|
670
704
|
}
|
|
671
|
-
|
|
705
|
+
// Re-append any messages that arrived during compaction
|
|
706
|
+
if (lateMessages.trim()) {
|
|
707
|
+
fs.appendFileSync(msgFile, lateMessages);
|
|
708
|
+
log.info('Re-appended ' + lateMessages.trim().split('\n').length + ' messages that arrived during compaction');
|
|
709
|
+
}
|
|
710
|
+
lastReadOffset = fs.statSync(msgFile).size;
|
|
672
711
|
|
|
673
712
|
// Trim consumed ID files — keep only IDs still in active messages
|
|
674
713
|
const activeIds = new Set(active.map(m => m.id));
|
|
@@ -840,6 +879,21 @@ function saveWorkflows(workflows) {
|
|
|
840
879
|
});
|
|
841
880
|
}
|
|
842
881
|
|
|
882
|
+
// Save a checkpoint after a workflow step completes
|
|
883
|
+
function saveWorkflowCheckpoint(wf, step) {
|
|
884
|
+
if (!wf.checkpoints) wf.checkpoints = [];
|
|
885
|
+
wf.checkpoints.push({
|
|
886
|
+
step_id: step.id,
|
|
887
|
+
step_description: step.description,
|
|
888
|
+
completed_at: step.completed_at,
|
|
889
|
+
completed_by: step.assignee || registeredName,
|
|
890
|
+
output: step.verification || step.notes || null,
|
|
891
|
+
files_changed: step.files_changed || [],
|
|
892
|
+
step_states: wf.steps.map(s => ({ id: s.id, status: s.status, assignee: s.assignee || null })),
|
|
893
|
+
});
|
|
894
|
+
if (wf.checkpoints.length > 100) wf.checkpoints = wf.checkpoints.slice(-100);
|
|
895
|
+
}
|
|
896
|
+
|
|
843
897
|
// --- Autonomous mode detection ---
|
|
844
898
|
function isAutonomousMode() {
|
|
845
899
|
const workflows = getWorkflows();
|
|
@@ -878,6 +932,25 @@ function findReadySteps(workflow) {
|
|
|
878
932
|
});
|
|
879
933
|
}
|
|
880
934
|
|
|
935
|
+
const PLATFORM_SKILLS = {
|
|
936
|
+
claude: ['terminal', 'file-editing', 'mcp', 'long-context', 'code-generation'],
|
|
937
|
+
anthropic: ['terminal', 'file-editing', 'mcp', 'long-context', 'code-generation'],
|
|
938
|
+
gemini: ['terminal', 'file-editing', 'mcp', 'web-search', 'multimodal'],
|
|
939
|
+
google: ['terminal', 'file-editing', 'mcp', 'web-search', 'multimodal'],
|
|
940
|
+
cursor: ['ide-integrated', 'file-editing', 'mcp', 'code-generation', 'linting'],
|
|
941
|
+
vscode: ['ide-integrated', 'file-editing', 'mcp', 'code-completion'],
|
|
942
|
+
copilot: ['ide-integrated', 'file-editing', 'mcp', 'code-completion'],
|
|
943
|
+
antigravity: ['ide-integrated', 'file-editing', 'mcp', 'agentic'],
|
|
944
|
+
openai: ['terminal', 'file-editing', 'sandboxed', 'code-generation'],
|
|
945
|
+
codex: ['terminal', 'file-editing', 'sandboxed', 'code-generation'],
|
|
946
|
+
ollama: ['local-model', 'offline', 'customizable'],
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
function getPlatformSkills(provider) {
|
|
950
|
+
if (!provider || provider === 'unknown') return [];
|
|
951
|
+
return PLATFORM_SKILLS[provider.toLowerCase()] || ['code-generation', 'file-editing'];
|
|
952
|
+
}
|
|
953
|
+
|
|
881
954
|
function findUnassignedTasks(skills) {
|
|
882
955
|
const tasks = getTasks();
|
|
883
956
|
// Exclude blocked_permanent tasks and tasks this agent already failed
|
|
@@ -897,18 +970,23 @@ function findUnassignedTasks(skills) {
|
|
|
897
970
|
const words = ((t.title || '') + ' ' + (t.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
|
|
898
971
|
words.forEach(w => historyKeywords.add(w));
|
|
899
972
|
}
|
|
900
|
-
// Add explicit skills
|
|
973
|
+
// Add explicit skills from function param AND agent card
|
|
901
974
|
if (skills) skills.forEach(s => historyKeywords.add(s.toLowerCase()));
|
|
975
|
+
const cards = readJsonFile(AGENT_CARDS_FILE) || {};
|
|
976
|
+
const myCard = cards[registeredName];
|
|
977
|
+
if (myCard && myCard.skills) myCard.skills.forEach(s => historyKeywords.add(s));
|
|
978
|
+
// Platform skills get half weight (shared across agents, less differentiating)
|
|
979
|
+
const platformSkillSet = new Set(myCard && myCard.platform_skills ? myCard.platform_skills : []);
|
|
902
980
|
|
|
903
981
|
// Score each task by affinity (keyword overlap with agent's history + skills)
|
|
904
982
|
// Scale fix: cache task keyword sets to avoid O(N*M) recomputation at 100 agents
|
|
905
983
|
return pending.sort((a, b) => {
|
|
906
984
|
const aKey = 'taskwords_' + a.id;
|
|
907
985
|
const bKey = 'taskwords_' + b.id;
|
|
908
|
-
const aWords = cachedRead(aKey, () => ((a.title || '') + ' ' + (a.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3),
|
|
909
|
-
const bWords = cachedRead(bKey, () => ((b.title || '') + ' ' + (b.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3),
|
|
910
|
-
const aScore = aWords.
|
|
911
|
-
const bScore = bWords.
|
|
986
|
+
const aWords = cachedRead(aKey, () => ((a.title || '') + ' ' + (a.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3), SERVER_CONFIG.WORD_CACHE_TTL_MS);
|
|
987
|
+
const bWords = cachedRead(bKey, () => ((b.title || '') + ' ' + (b.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3), SERVER_CONFIG.WORD_CACHE_TTL_MS);
|
|
988
|
+
const aScore = aWords.reduce((s, w) => s + (historyKeywords.has(w) ? (platformSkillSet.has(w) ? 0.5 : 1) : 0), 0);
|
|
989
|
+
const bScore = bWords.reduce((s, w) => s + (historyKeywords.has(w) ? (platformSkillSet.has(w) ? 0.5 : 1) : 0), 0);
|
|
912
990
|
return bScore - aScore;
|
|
913
991
|
});
|
|
914
992
|
}
|
|
@@ -952,7 +1030,7 @@ function findStealableWork() {
|
|
|
952
1030
|
function findHelpRequests() {
|
|
953
1031
|
// Scale fix: only read last 50 messages — help requests are always recent
|
|
954
1032
|
const messages = tailReadJsonl(getMessagesFile(currentBranch), 50);
|
|
955
|
-
const recentCutoff = Date.now() -
|
|
1033
|
+
const recentCutoff = Date.now() - SERVER_CONFIG.AUTONOMOUS_LISTEN_MS * 10;
|
|
956
1034
|
return messages.filter(m => {
|
|
957
1035
|
if (new Date(m.timestamp).getTime() < recentCutoff) return false;
|
|
958
1036
|
if (m.from === registeredName) return false;
|
|
@@ -1054,12 +1132,14 @@ let _guideCache = { key: null, result: null };
|
|
|
1054
1132
|
function buildGuide(level = 'standard') {
|
|
1055
1133
|
const agents = getAgents();
|
|
1056
1134
|
const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
|
|
1057
|
-
const
|
|
1135
|
+
const config = getConfig();
|
|
1136
|
+
const mode = config.conversation_mode || 'direct';
|
|
1137
|
+
const coordMode = config.coordinator_mode || 'responsive';
|
|
1058
1138
|
|
|
1059
1139
|
// Cache check: reuse cached guide if nothing changed (saves rebuilding 20-50 rules)
|
|
1060
1140
|
let rulesMtime = 0;
|
|
1061
1141
|
try { rulesMtime = fs.existsSync(RULES_FILE) ? fs.statSync(RULES_FILE).mtimeMs : 0; } catch {}
|
|
1062
|
-
const cacheKey = `${level}:${aliveCount}:${mode}:${registeredName}:${rulesMtime}`;
|
|
1142
|
+
const cacheKey = `${level}:${aliveCount}:${mode}:${coordMode}:${registeredName}:${rulesMtime}`;
|
|
1063
1143
|
if (_guideCache.key === cacheKey && _guideCache.result) return _guideCache.result;
|
|
1064
1144
|
|
|
1065
1145
|
const channels = getChannelsData();
|
|
@@ -1072,6 +1152,7 @@ function buildGuide(level = 'standard') {
|
|
|
1072
1152
|
const isQualityLead = myRole === 'quality';
|
|
1073
1153
|
const isMonitor = myRole === 'monitor';
|
|
1074
1154
|
const isAdvisor = myRole === 'advisor';
|
|
1155
|
+
const isLeadRole = myRole === 'lead' || myRole === 'manager' || myRole === 'coordinator';
|
|
1075
1156
|
let qualityLeadName = null;
|
|
1076
1157
|
for (const [pName, prof] of Object.entries(profiles)) {
|
|
1077
1158
|
if (prof.role && prof.role.toLowerCase() === 'quality' && pName !== registeredName) { qualityLeadName = pName; break; }
|
|
@@ -1143,11 +1224,21 @@ function buildGuide(level = 'standard') {
|
|
|
1143
1224
|
try {
|
|
1144
1225
|
const content = fs.readFileSync(guideFile, 'utf8').trim();
|
|
1145
1226
|
if (content) projectRules = content.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('#')).map(l => l.replace(/^[-*]\s*/, '').trim()).filter(Boolean);
|
|
1146
|
-
} catch {}
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
// Inject dashboard-managed rules into guide
|
|
1150
|
-
const
|
|
1227
|
+
} catch (e) { log.debug("guide file read failed:", e.message); }
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Inject dashboard-managed rules into guide (filtered by scope)
|
|
1231
|
+
const myProvider = (() => {
|
|
1232
|
+
const ag = getAgents();
|
|
1233
|
+
return ((ag[registeredName] && ag[registeredName].provider) || '').toLowerCase();
|
|
1234
|
+
})();
|
|
1235
|
+
const dashboardRules = getRules().filter(r => {
|
|
1236
|
+
if (!r.active) return false;
|
|
1237
|
+
if (r.scope_role && r.scope_role !== (myRole || '').toLowerCase()) return false;
|
|
1238
|
+
if (r.scope_provider && r.scope_provider !== myProvider) return false;
|
|
1239
|
+
if (r.scope_agent && r.scope_agent !== registeredName) return false;
|
|
1240
|
+
return true;
|
|
1241
|
+
});
|
|
1151
1242
|
if (dashboardRules.length > 0) {
|
|
1152
1243
|
for (const r of dashboardRules) {
|
|
1153
1244
|
rules.push(`[${r.category.toUpperCase()}] ${r.text}`);
|
|
@@ -1170,7 +1261,7 @@ function buildGuide(level = 'standard') {
|
|
|
1170
1261
|
quality_lead: qualityLeadName || undefined,
|
|
1171
1262
|
tool_categories: {
|
|
1172
1263
|
'WORK LOOP': 'get_work, verify_and_advance, retry_with_improvement',
|
|
1173
|
-
'MESSAGING': 'send_message, broadcast, check_messages, get_history, handoff, share_file',
|
|
1264
|
+
'MESSAGING': 'send_message, broadcast, check_messages, consume_messages, get_history, handoff, share_file',
|
|
1174
1265
|
'COORDINATION': 'get_briefing, log_decision, get_decisions, kb_write, kb_read, kb_list',
|
|
1175
1266
|
'TASKS': 'create_task, update_task, list_tasks, suggest_task',
|
|
1176
1267
|
'QUALITY': 'request_review, submit_review',
|
|
@@ -1189,6 +1280,17 @@ function buildGuide(level = 'standard') {
|
|
|
1189
1280
|
}
|
|
1190
1281
|
}
|
|
1191
1282
|
|
|
1283
|
+
// Lead/Coordinator mode: responsive (stay with human) vs autonomous (run in listen loop)
|
|
1284
|
+
if (isLeadRole && aliveCount >= 2) {
|
|
1285
|
+
const coordinatorMode = getConfig().coordinator_mode || 'responsive';
|
|
1286
|
+
if (coordinatorMode === 'responsive') {
|
|
1287
|
+
rules.push('RESPONSIVE COORDINATOR PATTERN: Use consume_messages() at the start of each interaction to check for agent updates non-blockingly. Process all returned messages, assign work, then return to the human immediately. Do NOT block in listen() — you need to stay responsive to both agents and the user.');
|
|
1288
|
+
} else {
|
|
1289
|
+
rules.push('AUTONOMOUS COORDINATOR PATTERN: Use listen() to wait for agent results. Process responses, delegate follow-up work, and continue the listen loop. Only return to the human when all tasks are complete or when you hit a blocker that requires human input.');
|
|
1290
|
+
}
|
|
1291
|
+
rules.push('CRITICAL: You are a Coordinator. You MUST NOT edit files, write code, or use tools like Edit/Write/Bash for code changes. Your tools are: send_message, create_task, update_task, create_workflow, advance_workflow, workflow_status, list_tasks, consume_messages, broadcast, kb_write, kb_read, log_decision. Delegate ALL code work to other agents.');
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1192
1294
|
// Tier 0 — THE one rule (always included at every level)
|
|
1193
1295
|
const listenCmd = isManagedMode() ? 'listen()' : (mode === 'group' ? 'listen_group()' : 'listen()');
|
|
1194
1296
|
rules.push(`AFTER EVERY ACTION, call ${listenCmd}. This is how you receive messages. NEVER skip this. NEVER use sleep(). NEVER poll with check_messages(). ${listenCmd} is your ONLY way to receive messages.`);
|
|
@@ -1247,11 +1349,21 @@ function buildGuide(level = 'standard') {
|
|
|
1247
1349
|
try {
|
|
1248
1350
|
const content = fs.readFileSync(guideFile, 'utf8').trim();
|
|
1249
1351
|
if (content) projectRules = content.split(/\r?\n/).filter(l => l.trim() && !l.startsWith('#')).map(l => l.replace(/^[-*]\s*/, '').trim()).filter(Boolean);
|
|
1250
|
-
} catch {}
|
|
1251
|
-
}
|
|
1252
|
-
|
|
1253
|
-
// Inject dashboard-managed rules into guide
|
|
1254
|
-
const
|
|
1352
|
+
} catch (e) { log.debug("guide file read failed:", e.message); }
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// Inject dashboard-managed rules into guide (filtered by scope)
|
|
1356
|
+
const agentProvider = (() => {
|
|
1357
|
+
const ag = getAgents();
|
|
1358
|
+
return ((ag[registeredName] && ag[registeredName].provider) || '').toLowerCase();
|
|
1359
|
+
})();
|
|
1360
|
+
const dashboardRules = getRules().filter(r => {
|
|
1361
|
+
if (!r.active) return false;
|
|
1362
|
+
if (r.scope_role && r.scope_role !== myRole) return false;
|
|
1363
|
+
if (r.scope_provider && r.scope_provider !== agentProvider) return false;
|
|
1364
|
+
if (r.scope_agent && r.scope_agent !== registeredName) return false;
|
|
1365
|
+
return true;
|
|
1366
|
+
});
|
|
1255
1367
|
if (dashboardRules.length > 0) {
|
|
1256
1368
|
for (const r of dashboardRules) {
|
|
1257
1369
|
rules.push(`[${r.category.toUpperCase()}] ${r.text}`);
|
|
@@ -1266,7 +1378,7 @@ function buildGuide(level = 'standard') {
|
|
|
1266
1378
|
? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait.'
|
|
1267
1379
|
: '1. Call get_briefing() for project context. 2. Call listen_group() to join. 3. Respond and listen_group() again.',
|
|
1268
1380
|
tool_categories: {
|
|
1269
|
-
'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, search_messages, handoff, share_file',
|
|
1381
|
+
'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, consume_messages, get_history, get_summary, search_messages, handoff, share_file',
|
|
1270
1382
|
'COORDINATION': 'get_briefing, log_decision, get_decisions, kb_write, kb_read, kb_list, call_vote, cast_vote, vote_status',
|
|
1271
1383
|
'TASKS': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task',
|
|
1272
1384
|
'QUALITY': 'update_progress, get_progress, request_review, submit_review, get_reputation',
|
|
@@ -1292,6 +1404,17 @@ function buildGuide(level = 'standard') {
|
|
|
1292
1404
|
};
|
|
1293
1405
|
}
|
|
1294
1406
|
|
|
1407
|
+
// Task reminder: show agent's pending/in_progress tasks so they remember to update them
|
|
1408
|
+
if (registeredName) {
|
|
1409
|
+
try {
|
|
1410
|
+
const myTasks = getTasks().filter(t => t.assignee === registeredName && (t.status === 'pending' || t.status === 'in_progress'));
|
|
1411
|
+
if (myTasks.length > 0) {
|
|
1412
|
+
result.your_tasks = myTasks.map(t => ({ id: t.id, title: t.title, status: t.status }));
|
|
1413
|
+
rules.push(`TASK STATUS: You have ${myTasks.length} task(s). Use update_task(task_id, "in_progress") when starting and update_task(task_id, "done") when complete. Your tasks: ${myTasks.map(t => t.id + ' "' + t.title.substring(0, 40) + '" (' + t.status + ')').join('; ')}`);
|
|
1414
|
+
}
|
|
1415
|
+
} catch (e) { log.debug('task reminder in guide failed:', e.message); }
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1295
1418
|
// Cache the result for subsequent calls with same params
|
|
1296
1419
|
_guideCache = { key: cacheKey, result };
|
|
1297
1420
|
return result;
|
|
@@ -1299,102 +1422,152 @@ function buildGuide(level = 'standard') {
|
|
|
1299
1422
|
|
|
1300
1423
|
// --- Tool implementations ---
|
|
1301
1424
|
|
|
1302
|
-
function toolRegister(name, provider = null) {
|
|
1425
|
+
function toolRegister(name, provider = null, skills = null) {
|
|
1303
1426
|
ensureDataDir();
|
|
1304
1427
|
migrateIfNeeded(); // run data migrations on first register
|
|
1305
1428
|
sanitizeName(name);
|
|
1306
1429
|
lockAgentsFile();
|
|
1307
1430
|
|
|
1308
1431
|
try {
|
|
1309
|
-
const agents = getAgents();
|
|
1432
|
+
const agents = getAgents(true);
|
|
1310
1433
|
if (agents[name] && agents[name].pid !== process.pid && isPidAlive(agents[name].pid, agents[name].last_activity)) {
|
|
1311
1434
|
return { error: `Agent "${name}" is already registered by a live process. Choose a different name.` };
|
|
1312
1435
|
}
|
|
1313
1436
|
|
|
1314
|
-
//
|
|
1315
|
-
if (agents[name] &&
|
|
1316
|
-
|
|
1317
|
-
if (registeredToken && registeredToken !== agents[name].token) {
|
|
1318
|
-
return { error: `Agent "${name}" was previously registered by another process. Choose a different name.` };
|
|
1319
|
-
}
|
|
1437
|
+
// Dead agent name reclaim — allow any process to take a dead agent's name
|
|
1438
|
+
if (agents[name] && !isPidAlive(agents[name].pid, agents[name].last_activity)) {
|
|
1439
|
+
log.info(`Agent "${name}" reclaimed (previous PID ${agents[name].pid} is dead)`);
|
|
1320
1440
|
}
|
|
1321
1441
|
|
|
1322
1442
|
// Prevent re-registration under a different name from the same process
|
|
1443
|
+
// Exception: if registeredName was set by autoReclaimDeadSeat() (not an explicit call), allow override
|
|
1323
1444
|
if (registeredName && registeredName !== name) {
|
|
1324
|
-
|
|
1325
|
-
|
|
1445
|
+
if (!autoReclaimedName) {
|
|
1446
|
+
unlockAgentsFile();
|
|
1447
|
+
return { error: `Already registered as "${registeredName}". Cannot change name mid-session.`, current_name: registeredName };
|
|
1448
|
+
}
|
|
1449
|
+
// Auto-reclaimed identity: clean up the old seat before taking the new name
|
|
1450
|
+
const oldName = registeredName;
|
|
1451
|
+
log.info(`Auto-reclaimed seat "${oldName}" overridden by explicit register("${name}")`);
|
|
1452
|
+
// Stop the auto-reclaim heartbeat
|
|
1453
|
+
if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
|
|
1454
|
+
// Delete the stale heartbeat file for the old agent so it shows as offline
|
|
1455
|
+
try {
|
|
1456
|
+
const oldHbFile = heartbeatFile(oldName);
|
|
1457
|
+
if (fs.existsSync(oldHbFile)) fs.unlinkSync(oldHbFile);
|
|
1458
|
+
} catch (e) { log.debug(`cleanup heartbeat for "${oldName}" failed:`, e.message); }
|
|
1459
|
+
registeredName = null;
|
|
1460
|
+
registeredToken = null;
|
|
1461
|
+
autoReclaimedName = false;
|
|
1326
1462
|
}
|
|
1327
1463
|
|
|
1328
1464
|
const now = new Date().toISOString();
|
|
1329
|
-
const token =
|
|
1330
|
-
|
|
1465
|
+
const token = generateToken();
|
|
1466
|
+
const agentEntry = { pid: process.pid, ppid: process.ppid, timestamp: now, last_activity: now, last_listened_at: now, provider: provider || 'unknown', branch: currentBranch, token, started_at: now };
|
|
1467
|
+
if (process.env.CLAUDE_SESSION_ID) agentEntry.claude_session_id = process.env.CLAUDE_SESSION_ID;
|
|
1468
|
+
agents[name] = agentEntry;
|
|
1331
1469
|
saveAgents(agents);
|
|
1332
1470
|
registeredName = name;
|
|
1333
|
-
|
|
1471
|
+
registeredToken = token;
|
|
1472
|
+
|
|
1473
|
+
// Auto-create profile if not exists
|
|
1474
|
+
const profiles = getProfiles();
|
|
1475
|
+
if (!profiles[name]) {
|
|
1476
|
+
profiles[name] = { display_name: name, avatar: '', bio: '', role: '', created_at: now };
|
|
1477
|
+
saveProfiles(profiles);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Save agent card with skills (merge platform defaults + explicit)
|
|
1481
|
+
const cards = readJsonFile(AGENT_CARDS_FILE) || {};
|
|
1482
|
+
const explicitSkills = Array.isArray(skills) ? skills.map(s => String(s).toLowerCase().substring(0, 30)).slice(0, 20) : [];
|
|
1483
|
+
const platformSkills = getPlatformSkills(provider);
|
|
1484
|
+
const mergedSkills = [...new Set([...explicitSkills, ...platformSkills])];
|
|
1485
|
+
cards[name] = {
|
|
1486
|
+
name,
|
|
1487
|
+
provider: provider || 'unknown',
|
|
1488
|
+
skills: mergedSkills,
|
|
1489
|
+
platform_skills: platformSkills,
|
|
1490
|
+
registered_at: now,
|
|
1491
|
+
};
|
|
1492
|
+
writeJsonFile(AGENT_CARDS_FILE, cards);
|
|
1334
1493
|
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1494
|
+
// Start heartbeat — updates last_activity every 10s so dashboard knows we're alive
|
|
1495
|
+
// Deterministic jitter per agent to spread writes across the interval (prevents lock storms at 10 agents)
|
|
1496
|
+
const heartbeatJitter = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0) % 2000;
|
|
1497
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
1498
|
+
heartbeatInterval = setInterval(() => {
|
|
1499
|
+
try {
|
|
1500
|
+
// Scale fix: write per-agent heartbeat file instead of lock+read+write agents.json
|
|
1501
|
+
// Eliminates write contention — each agent writes only its own file, no locking needed
|
|
1502
|
+
// Pass isListenCall=true when agent is actively in listen() so other agents
|
|
1503
|
+
// see a fresh last_listen_call timestamp and don't send false-positive nudges.
|
|
1504
|
+
touchHeartbeat(registeredName, _isCurrentlyListening);
|
|
1505
|
+
const agents = getAgents(); // cached + merges heartbeat files automatically
|
|
1506
|
+
// Managed mode: detect dead manager and dead turn holder
|
|
1507
|
+
if (isManagedMode()) {
|
|
1508
|
+
const managed = getManagedConfig();
|
|
1509
|
+
let managedChanged = false;
|
|
1510
|
+
|
|
1511
|
+
// Dead manager detection
|
|
1512
|
+
if (managed.manager && managed.manager !== registeredName) {
|
|
1513
|
+
if (agents[managed.manager] && !isPidAlive(agents[managed.manager].pid, agents[managed.manager].last_activity)) {
|
|
1514
|
+
managed.manager = null;
|
|
1515
|
+
managed.floor = 'closed';
|
|
1516
|
+
managed.turn_current = null;
|
|
1517
|
+
managed.turn_queue = [];
|
|
1518
|
+
managedChanged = true;
|
|
1519
|
+
saveManagedConfig(managed);
|
|
1520
|
+
broadcastSystemMessage(`[SYSTEM] Manager disconnected. Call claim_manager() to take over as the new manager.`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1341
1523
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
const managed = getManagedConfig();
|
|
1355
|
-
let managedChanged = false;
|
|
1356
|
-
|
|
1357
|
-
// Dead manager detection
|
|
1358
|
-
if (managed.manager && managed.manager !== registeredName) {
|
|
1359
|
-
if (agents[managed.manager] && !isPidAlive(agents[managed.manager].pid, agents[managed.manager].last_activity)) {
|
|
1360
|
-
managed.manager = null;
|
|
1361
|
-
managed.floor = 'closed';
|
|
1362
|
-
managed.turn_current = null;
|
|
1363
|
-
managed.turn_queue = [];
|
|
1364
|
-
managedChanged = true;
|
|
1365
|
-
saveManagedConfig(managed);
|
|
1366
|
-
broadcastSystemMessage(`[SYSTEM] Manager disconnected. Call claim_manager() to take over as the new manager.`);
|
|
1524
|
+
// Dead turn holder detection — unstick the floor
|
|
1525
|
+
if (!managedChanged && managed.turn_current && managed.turn_current !== registeredName && managed.manager) {
|
|
1526
|
+
if (agents[managed.turn_current] && !isPidAlive(agents[managed.turn_current].pid, agents[managed.turn_current].last_activity)) {
|
|
1527
|
+
const deadAgent = managed.turn_current;
|
|
1528
|
+
managed.turn_current = null;
|
|
1529
|
+
managed.floor = 'closed';
|
|
1530
|
+
managed.turn_queue = [];
|
|
1531
|
+
saveManagedConfig(managed);
|
|
1532
|
+
if (managed.manager !== registeredName) {
|
|
1533
|
+
sendSystemMessage(managed.manager, `[FLOOR] ${deadAgent} disconnected while holding the floor. Floor returned to you.`);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1367
1536
|
}
|
|
1368
1537
|
}
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
managed.floor = 'closed';
|
|
1376
|
-
managed.turn_queue = [];
|
|
1377
|
-
saveManagedConfig(managed);
|
|
1378
|
-
if (managed.manager !== registeredName) {
|
|
1379
|
-
sendSystemMessage(managed.manager, `[FLOOR] ${deadAgent} disconnected while holding the floor. Floor returned to you.`);
|
|
1538
|
+
// Clean stale listening_since flags (listen times out at 5min, clear after 6min)
|
|
1539
|
+
for (const [aName, aInfo] of Object.entries(agents)) {
|
|
1540
|
+
if (aInfo.listening_since) {
|
|
1541
|
+
const listenAge = Date.now() - new Date(aInfo.listening_since).getTime();
|
|
1542
|
+
if (listenAge > 360000) {
|
|
1543
|
+
aInfo.listening_since = null;
|
|
1380
1544
|
}
|
|
1381
1545
|
}
|
|
1382
1546
|
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1547
|
+
// Agent status change notifications — detect agents going offline/online
|
|
1548
|
+
detectAgentStatusChanges(agents);
|
|
1549
|
+
// Auto-nudge system: detect agents that haven't called listen() recently
|
|
1550
|
+
checkListenCompliance(agents);
|
|
1551
|
+
// Snapshot dead agents BEFORE cleanup (for auto-recovery)
|
|
1552
|
+
snapshotDeadAgents(agents);
|
|
1553
|
+
// Clean up file locks held by dead agents
|
|
1554
|
+
cleanStaleLocks();
|
|
1555
|
+
cleanStaleChannelMembers();
|
|
1556
|
+
// Auto-escalation: notify team about long-blocked tasks
|
|
1557
|
+
escalateBlockedTasks();
|
|
1558
|
+
// Stand-up meetings: periodic team check-ins
|
|
1559
|
+
triggerStandupIfDue();
|
|
1560
|
+
// Auto-reassign stuck workflow steps from dead agents
|
|
1561
|
+
checkStuckWorkflowSteps();
|
|
1562
|
+
// Stale task detection: warn about tasks in_progress for >30 minutes without update
|
|
1563
|
+
checkStaleTasks();
|
|
1564
|
+
// Self-healing: silently reclaim tasks from dead agents, poison-pill at retry 3
|
|
1565
|
+
selfHealingWatchdog();
|
|
1566
|
+
// Watchdog: nudge idle agents, reassign stuck work (autonomous mode only)
|
|
1567
|
+
watchdogCheck();
|
|
1568
|
+
} catch (e) { log.warn("heartbeat loop error:", e.message); }
|
|
1569
|
+
}, 10000 + heartbeatJitter);
|
|
1570
|
+
heartbeatInterval.unref(); // Don't prevent process exit
|
|
1398
1571
|
|
|
1399
1572
|
// Fire join event + recovery data for returning agents
|
|
1400
1573
|
const config = getConfig();
|
|
@@ -1456,7 +1629,7 @@ function toolRegister(name, provider = null) {
|
|
|
1456
1629
|
// Clean up snapshot after loading
|
|
1457
1630
|
try { fs.unlinkSync(recoveryFile); } catch {}
|
|
1458
1631
|
}
|
|
1459
|
-
} catch {}
|
|
1632
|
+
} catch (e) { log.debug("recovery file parse failed:", e.message); }
|
|
1460
1633
|
}
|
|
1461
1634
|
|
|
1462
1635
|
// Notify other agents
|
|
@@ -1470,7 +1643,7 @@ function toolRegister(name, provider = null) {
|
|
|
1470
1643
|
if (roleAssignments && roleAssignments[name]) {
|
|
1471
1644
|
result.your_role = roleAssignments[name];
|
|
1472
1645
|
}
|
|
1473
|
-
} catch {}
|
|
1646
|
+
} catch (e) { log.debug("role assignment failed:", e.message); }
|
|
1474
1647
|
}
|
|
1475
1648
|
|
|
1476
1649
|
return result;
|
|
@@ -1481,15 +1654,22 @@ function toolRegister(name, provider = null) {
|
|
|
1481
1654
|
|
|
1482
1655
|
// Update last_activity timestamp for this agent
|
|
1483
1656
|
// Uses file lock to prevent race with heartbeat writes
|
|
1484
|
-
function touchActivity() {
|
|
1657
|
+
function touchActivity(isListenCall = false) {
|
|
1485
1658
|
if (!registeredName) return;
|
|
1486
1659
|
// Scale fix: write per-agent heartbeat file instead of lock+write agents.json
|
|
1487
|
-
touchHeartbeat(registeredName);
|
|
1660
|
+
touchHeartbeat(registeredName, isListenCall);
|
|
1488
1661
|
}
|
|
1489
1662
|
|
|
1490
1663
|
// Set or clear the listening_since flag
|
|
1491
1664
|
function setListening(isListening) {
|
|
1492
1665
|
if (!registeredName) return;
|
|
1666
|
+
_isCurrentlyListening = !!isListening;
|
|
1667
|
+
|
|
1668
|
+
// Track listen calls in heartbeat for auto-nudge system
|
|
1669
|
+
if (isListening) {
|
|
1670
|
+
touchActivity(true); // Mark as listen call
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1493
1673
|
try {
|
|
1494
1674
|
lockAgentsFile();
|
|
1495
1675
|
try {
|
|
@@ -1502,7 +1682,7 @@ function setListening(isListening) {
|
|
|
1502
1682
|
saveAgents(agents);
|
|
1503
1683
|
}
|
|
1504
1684
|
} finally { unlockAgentsFile(); }
|
|
1505
|
-
} catch {}
|
|
1685
|
+
} catch (e) { log.debug("register workspace status failed:", e.message); }
|
|
1506
1686
|
}
|
|
1507
1687
|
|
|
1508
1688
|
function toolListAgents() {
|
|
@@ -1513,13 +1693,26 @@ function toolListAgents() {
|
|
|
1513
1693
|
const alive = isPidAlive(info.pid, info.last_activity);
|
|
1514
1694
|
const lastActivity = info.last_activity || info.timestamp;
|
|
1515
1695
|
const idleSeconds = Math.floor((Date.now() - new Date(lastActivity).getTime()) / 1000);
|
|
1696
|
+
const hasHeartbeat = fs.existsSync(heartbeatFile(name));
|
|
1516
1697
|
const profile = profiles[name] || {};
|
|
1698
|
+
|
|
1699
|
+
let status;
|
|
1700
|
+
if (alive) {
|
|
1701
|
+
status = (info.listening_since) ? 'listening' : idleSeconds > 30 ? 'idle' : 'working';
|
|
1702
|
+
} else if (!hasHeartbeat) {
|
|
1703
|
+
status = 'unknown';
|
|
1704
|
+
} else if (idleSeconds <= 120) {
|
|
1705
|
+
status = 'stale';
|
|
1706
|
+
} else {
|
|
1707
|
+
status = 'offline';
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1517
1710
|
result[name] = {
|
|
1518
1711
|
alive,
|
|
1519
1712
|
registered_at: info.timestamp,
|
|
1520
1713
|
last_activity: lastActivity,
|
|
1521
1714
|
idle_seconds: alive ? idleSeconds : null,
|
|
1522
|
-
status
|
|
1715
|
+
status,
|
|
1523
1716
|
listening_since: info.listening_since || null,
|
|
1524
1717
|
is_listening: !!(info.listening_since && alive),
|
|
1525
1718
|
last_listened_at: info.last_listened_at || null,
|
|
@@ -1534,12 +1727,15 @@ function toolListAgents() {
|
|
|
1534
1727
|
try {
|
|
1535
1728
|
const ws = getWorkspace(name);
|
|
1536
1729
|
if (ws._status) result[name].current_status = ws._status;
|
|
1537
|
-
} catch {}
|
|
1730
|
+
} catch (e) { log.debug("workspace status read failed:", e.message); }
|
|
1731
|
+
|
|
1732
|
+
const ide = readIdeActivity(DATA_DIR, name);
|
|
1733
|
+
if (ide) applyIdeActivityHint(result[name], ide, { dataDir: DATA_DIR, agentName: name });
|
|
1538
1734
|
}
|
|
1539
1735
|
return { agents: result };
|
|
1540
1736
|
}
|
|
1541
1737
|
|
|
1542
|
-
async function toolSendMessage(content, to = null, reply_to = null, channel = null) {
|
|
1738
|
+
async function toolSendMessage(content, to = null, reply_to = null, channel = null, priority = null) {
|
|
1543
1739
|
if (!registeredName) {
|
|
1544
1740
|
return { error: 'You must call register() first' };
|
|
1545
1741
|
}
|
|
@@ -1554,7 +1750,8 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1554
1750
|
// Send-after-listen enforcement: must call listen_group between sends in group mode
|
|
1555
1751
|
// Autonomous mode: relaxed to 5 sends per listen cycle
|
|
1556
1752
|
const effectiveSendLimit = isAutonomousMode() ? 5 : sendLimit;
|
|
1557
|
-
|
|
1753
|
+
const myRole = (getProfiles()[registeredName] || {}).role;
|
|
1754
|
+
if (isGroupMode() && sendsSinceLastListen >= effectiveSendLimit && myRole !== 'Coordinator') {
|
|
1558
1755
|
return { error: `You must call listen_group() before sending again. You've sent ${sendsSinceLastListen} message(s) without listening (limit: ${effectiveSendLimit}). This prevents message storms.` };
|
|
1559
1756
|
}
|
|
1560
1757
|
|
|
@@ -1678,7 +1875,8 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1678
1875
|
const agents = getAgents();
|
|
1679
1876
|
const otherAgents = Object.keys(agents).filter(n => n !== registeredName);
|
|
1680
1877
|
|
|
1681
|
-
|
|
1878
|
+
// Allow sending to __user__ (dashboard human) even when no other agents are registered
|
|
1879
|
+
if (otherAgents.length === 0 && to !== '__user__') {
|
|
1682
1880
|
return { error: 'No other agents registered' };
|
|
1683
1881
|
}
|
|
1684
1882
|
|
|
@@ -1691,7 +1889,8 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1691
1889
|
}
|
|
1692
1890
|
}
|
|
1693
1891
|
|
|
1694
|
-
|
|
1892
|
+
// Allow sending to __user__ (human via dashboard) even though they're not a registered agent
|
|
1893
|
+
if (to !== '__user__' && !agents[to]) {
|
|
1695
1894
|
return { error: `Agent "${to}" is not registered` };
|
|
1696
1895
|
}
|
|
1697
1896
|
|
|
@@ -1699,16 +1898,16 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1699
1898
|
return { error: 'Cannot send a message to yourself' };
|
|
1700
1899
|
}
|
|
1701
1900
|
|
|
1702
|
-
// Permission check
|
|
1703
|
-
if (!canSendTo(registeredName, to)) {
|
|
1901
|
+
// Permission check (skip for __user__ — human always has read access)
|
|
1902
|
+
if (to !== '__user__' && !canSendTo(registeredName, to)) {
|
|
1704
1903
|
return { error: `Permission denied: you are not allowed to send messages to "${to}"` };
|
|
1705
1904
|
}
|
|
1706
1905
|
|
|
1707
1906
|
const sizeErr = validateContentSize(content);
|
|
1708
1907
|
if (sizeErr) return sizeErr;
|
|
1709
1908
|
|
|
1710
|
-
// Check if recipient is alive — warn if dead
|
|
1711
|
-
const recipientAlive = isPidAlive(agents[to].pid, agents[to].last_activity);
|
|
1909
|
+
// Check if recipient is alive — warn if dead (skip for __user__ — human is always reachable)
|
|
1910
|
+
const recipientAlive = to === '__user__' ? true : isPidAlive(agents[to].pid, agents[to].last_activity);
|
|
1712
1911
|
|
|
1713
1912
|
// Resolve threading — search main messages + channel files
|
|
1714
1913
|
let thread_id = null;
|
|
@@ -1741,6 +1940,7 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1741
1940
|
to: isGroup ? '__group__' : to,
|
|
1742
1941
|
content,
|
|
1743
1942
|
timestamp: new Date().toISOString(),
|
|
1943
|
+
...(priority && ['critical', 'normal', 'low'].includes(priority) && { priority }),
|
|
1744
1944
|
...(isGroup && to && { addressed_to: [to] }),
|
|
1745
1945
|
...(channel && { channel }),
|
|
1746
1946
|
...(reply_to && { reply_to }),
|
|
@@ -1829,7 +2029,7 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1829
2029
|
result._decision_hint = `Related decision exists: "${overlap.decision}" (topic: ${overlap.topic || 'general'}). Check get_decisions() before re-debating.`;
|
|
1830
2030
|
}
|
|
1831
2031
|
}
|
|
1832
|
-
} catch {}
|
|
2032
|
+
} catch (e) { log.debug("listen channel watcher setup failed:", e.message); }
|
|
1833
2033
|
}
|
|
1834
2034
|
if (_cooldownApplied > 0) result.cooldown_applied_ms = _cooldownApplied;
|
|
1835
2035
|
if (channel) result.channel = channel;
|
|
@@ -1846,7 +2046,7 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1846
2046
|
}
|
|
1847
2047
|
if (!recipientAlive) {
|
|
1848
2048
|
result.warning = `Agent "${to}" appears offline (PID not running). Message queued but may not be received until they reconnect.`;
|
|
1849
|
-
} else if (agents[to] && !agents[to].listening_since) {
|
|
2049
|
+
} else if (to !== '__user__' && agents[to] && !agents[to].listening_since) {
|
|
1850
2050
|
result.note = `Agent "${to}" is currently working (not in listen mode). Message queued — they'll see it when they finish their current task and call listen_group().`;
|
|
1851
2051
|
}
|
|
1852
2052
|
|
|
@@ -1862,6 +2062,25 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
|
|
|
1862
2062
|
result.you_have_messages = myPending.length;
|
|
1863
2063
|
result.urgent = `You have ${myPending.length} unread message(s) waiting. Call listen_group() after this to read them.`;
|
|
1864
2064
|
}
|
|
2065
|
+
|
|
2066
|
+
// Coordinator enforcement: warn if sending work assignment without creating a task first
|
|
2067
|
+
const senderProfile = getProfiles()[registeredName];
|
|
2068
|
+
const senderRole = senderProfile && senderProfile.role ? senderProfile.role.toLowerCase() : '';
|
|
2069
|
+
const isSenderLead = senderRole === 'lead' || senderRole === 'manager' || senderRole === 'coordinator';
|
|
2070
|
+
if (isSenderLead && to && to !== '__user__' && to !== '__all__' && to !== '__group__') {
|
|
2071
|
+
const assignmentKeywords = /\b(implement|fix|build|add|create|update|redesign|refactor|write|deploy|test|review|research|investigate)\b/i;
|
|
2072
|
+
if (assignmentKeywords.test(content)) {
|
|
2073
|
+
const recentTasks = getTasks().filter(t => {
|
|
2074
|
+
if (t.assignee !== to) return false;
|
|
2075
|
+
const age = Date.now() - new Date(t.created_at).getTime();
|
|
2076
|
+
return age < 60000; // created in last 60 seconds
|
|
2077
|
+
});
|
|
2078
|
+
if (recentTasks.length === 0) {
|
|
2079
|
+
result.task_warning = `No task created for this assignment to ${to}. Use create_task(title, description, "${to}") to formally track this work.`;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
1865
2084
|
return result;
|
|
1866
2085
|
}
|
|
1867
2086
|
|
|
@@ -1880,7 +2099,8 @@ function toolBroadcast(content) {
|
|
|
1880
2099
|
|
|
1881
2100
|
// Send-after-listen enforcement applies to broadcast too
|
|
1882
2101
|
const effectiveSendLimitBcast = isAutonomousMode() ? 5 : sendLimit;
|
|
1883
|
-
|
|
2102
|
+
const myRole = (getProfiles()[registeredName] || {}).role;
|
|
2103
|
+
if (isGroupMode() && sendsSinceLastListen >= effectiveSendLimitBcast && myRole !== 'Coordinator') {
|
|
1884
2104
|
return { error: `You must call listen_group() before broadcasting again. You've sent ${sendsSinceLastListen} message(s) without listening (limit: ${effectiveSendLimitBcast}).` };
|
|
1885
2105
|
}
|
|
1886
2106
|
|
|
@@ -2043,8 +2263,13 @@ function toolCheckMessages(from = null) {
|
|
|
2043
2263
|
if (m.addressed_to && m.addressed_to.includes(registeredName)) addressedCount++;
|
|
2044
2264
|
}
|
|
2045
2265
|
|
|
2266
|
+
// Include pending notification count
|
|
2267
|
+
const allNotifs = getNotifications();
|
|
2268
|
+
const unreadNotifs = allNotifs.filter(n => !n.read_by.includes(registeredName));
|
|
2269
|
+
|
|
2046
2270
|
const result = {
|
|
2047
2271
|
count: unconsumed.length,
|
|
2272
|
+
pending_notifications: unreadNotifs.length,
|
|
2048
2273
|
// Scale fix: return previews not full content — agent gets full content via listen_group()
|
|
2049
2274
|
messages: unconsumed.map(m => ({
|
|
2050
2275
|
id: m.id,
|
|
@@ -2068,6 +2293,60 @@ function toolCheckMessages(from = null) {
|
|
|
2068
2293
|
return result;
|
|
2069
2294
|
}
|
|
2070
2295
|
|
|
2296
|
+
function toolConsumeMessages(from = null, limit = null) {
|
|
2297
|
+
if (!registeredName) {
|
|
2298
|
+
return { error: 'You must call register() first' };
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
let unconsumed = getUnconsumedMessages(registeredName, from);
|
|
2302
|
+
if (limit && limit > 0 && unconsumed.length > limit) {
|
|
2303
|
+
unconsumed = unconsumed.slice(0, limit);
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
if (unconsumed.length === 0) {
|
|
2307
|
+
return { success: true, count: 0, messages: [] };
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
// Mark all as consumed
|
|
2311
|
+
const consumed = getConsumedIds(registeredName);
|
|
2312
|
+
for (const msg of unconsumed) {
|
|
2313
|
+
consumed.add(msg.id);
|
|
2314
|
+
markAsRead(registeredName, msg.id);
|
|
2315
|
+
}
|
|
2316
|
+
saveConsumedIds(registeredName, consumed);
|
|
2317
|
+
|
|
2318
|
+
// Update read offset
|
|
2319
|
+
const msgFile = getMessagesFile(currentBranch);
|
|
2320
|
+
if (fs.existsSync(msgFile)) {
|
|
2321
|
+
lastReadOffset = fs.statSync(msgFile).size;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
touchActivity();
|
|
2325
|
+
|
|
2326
|
+
// Count remaining unconsumed after this batch
|
|
2327
|
+
const remaining = getUnconsumedMessages(registeredName, null);
|
|
2328
|
+
|
|
2329
|
+
const agents = getAgents();
|
|
2330
|
+
const agentsOnline = Object.entries(agents).filter(([, info]) => isPidAlive(info.pid, info.last_activity)).length;
|
|
2331
|
+
|
|
2332
|
+
return {
|
|
2333
|
+
success: true,
|
|
2334
|
+
count: unconsumed.length,
|
|
2335
|
+
messages: unconsumed.map(m => ({
|
|
2336
|
+
id: m.id,
|
|
2337
|
+
from: m.from,
|
|
2338
|
+
content: m.content,
|
|
2339
|
+
timestamp: m.timestamp,
|
|
2340
|
+
...(m.reply_to && { reply_to: m.reply_to }),
|
|
2341
|
+
...(m.thread_id && { thread_id: m.thread_id }),
|
|
2342
|
+
...(m.addressed_to && { addressed_to: m.addressed_to }),
|
|
2343
|
+
})),
|
|
2344
|
+
remaining: remaining.length,
|
|
2345
|
+
agents_online: agentsOnline,
|
|
2346
|
+
coordinator_mode: getConfig().coordinator_mode || 'responsive',
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2071
2350
|
function toolAckMessage(messageId) {
|
|
2072
2351
|
if (!registeredName) {
|
|
2073
2352
|
return { error: 'You must call register() first' };
|
|
@@ -2093,15 +2372,34 @@ function toolAckMessage(messageId) {
|
|
|
2093
2372
|
}
|
|
2094
2373
|
|
|
2095
2374
|
// Listen indefinitely — loops wait_for_reply in 5-min chunks until a message arrives
|
|
2096
|
-
async function toolListen(from = null) {
|
|
2375
|
+
async function toolListen(from = null, outcome = null, task_id = null, summary = null, mode = null) {
|
|
2097
2376
|
if (!registeredName) {
|
|
2098
2377
|
return { error: 'You must call register() first' };
|
|
2099
2378
|
}
|
|
2100
2379
|
|
|
2380
|
+
// Mode-based dispatch: explicit mode overrides auto-detection
|
|
2381
|
+
if (mode === 'codex') return toolListenCodex(from, outcome, task_id, summary);
|
|
2382
|
+
if (mode === 'group') return toolListenGroup(outcome, task_id, summary);
|
|
2383
|
+
|
|
2384
|
+
// Outcome validation: update task state before entering the wait loop
|
|
2385
|
+
if (outcome && outcome !== 'in_progress' && task_id) {
|
|
2386
|
+
const taskList = getTasks();
|
|
2387
|
+
const task = taskList.find(t => t.id === task_id);
|
|
2388
|
+
if (!task) {
|
|
2389
|
+
return { error: true, message: `Invalid task_id "${task_id}" — task does not exist. Check list_tasks() and call listen() again with the correct task_id.` };
|
|
2390
|
+
}
|
|
2391
|
+
if (task.assignee && task.assignee !== registeredName) {
|
|
2392
|
+
return { error: true, message: `Task "${task_id}" is assigned to ${task.assignee}, not to you (${registeredName}). You cannot update another agent's task via listen().` };
|
|
2393
|
+
}
|
|
2394
|
+
const statusMap = { completed: 'done', blocked: 'blocked', failed: 'blocked_permanent' };
|
|
2395
|
+
const newStatus = statusMap[outcome];
|
|
2396
|
+
if (newStatus) toolUpdateTask(task_id, newStatus, summary || '');
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2101
2399
|
// Auto-detect group/managed mode and delegate to toolListenGroup
|
|
2102
2400
|
// This prevents agents from calling the "wrong" listen function
|
|
2103
2401
|
if (isGroupMode() || isManagedMode()) {
|
|
2104
|
-
return toolListenGroup();
|
|
2402
|
+
return toolListenGroup(null, null, null);
|
|
2105
2403
|
}
|
|
2106
2404
|
|
|
2107
2405
|
setListening(true);
|
|
@@ -2114,9 +2412,13 @@ async function toolListen(from = null) {
|
|
|
2114
2412
|
consumed.add(msg.id);
|
|
2115
2413
|
saveConsumedIds(registeredName, consumed);
|
|
2116
2414
|
markAsRead(registeredName, msg.id);
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2415
|
+
// Only advance offset to end-of-file if this is the LAST unconsumed message.
|
|
2416
|
+
// Otherwise keep offset so next listen() call re-reads and finds remaining messages.
|
|
2417
|
+
if (existing.length <= 1) {
|
|
2418
|
+
const _mfL1 = getMessagesFile(currentBranch);
|
|
2419
|
+
if (fs.existsSync(_mfL1)) {
|
|
2420
|
+
lastReadOffset = fs.statSync(_mfL1).size;
|
|
2421
|
+
}
|
|
2120
2422
|
}
|
|
2121
2423
|
touchActivity();
|
|
2122
2424
|
setListening(false);
|
|
@@ -2152,7 +2454,9 @@ async function toolListen(from = null) {
|
|
|
2152
2454
|
const { messages: newMsgs, newOffset } = readNewMessages(lastReadOffset);
|
|
2153
2455
|
lastReadOffset = newOffset;
|
|
2154
2456
|
for (const msg of newMsgs) {
|
|
2155
|
-
if (
|
|
2457
|
+
if (consumed.has(msg.id)) continue;
|
|
2458
|
+
if (msg.to !== registeredName && msg.to !== '__group__' && msg.to !== '__all__') continue;
|
|
2459
|
+
if (msg.to === '__group__' && msg.from === registeredName) continue;
|
|
2156
2460
|
if (from && msg.from !== from && !msg.system) continue;
|
|
2157
2461
|
consumed.add(msg.id);
|
|
2158
2462
|
saveConsumedIds(registeredName, consumed);
|
|
@@ -2198,11 +2502,26 @@ async function toolListen(from = null) {
|
|
|
2198
2502
|
|
|
2199
2503
|
// Codex-compatible listen — returns after 90s (under Codex's 120s tool timeout)
|
|
2200
2504
|
// with retry:true so the agent knows to call again immediately
|
|
2201
|
-
async function toolListenCodex(from = null) {
|
|
2505
|
+
async function toolListenCodex(from = null, outcome = null, task_id = null, summary = null) {
|
|
2202
2506
|
if (!registeredName) {
|
|
2203
2507
|
return { error: 'You must call register() first' };
|
|
2204
2508
|
}
|
|
2205
2509
|
|
|
2510
|
+
// Outcome validation: update task state before entering the wait loop
|
|
2511
|
+
if (outcome && outcome !== 'in_progress' && task_id) {
|
|
2512
|
+
const taskList = getTasks();
|
|
2513
|
+
const task = taskList.find(t => t.id === task_id);
|
|
2514
|
+
if (!task) {
|
|
2515
|
+
return { error: true, message: `Invalid task_id "${task_id}" — task does not exist. Check list_tasks() and call listen_codex() again with the correct task_id.` };
|
|
2516
|
+
}
|
|
2517
|
+
if (task.assignee && task.assignee !== registeredName) {
|
|
2518
|
+
return { error: true, message: `Task "${task_id}" is assigned to ${task.assignee}, not to you (${registeredName}). You cannot update another agent's task via listen_codex().` };
|
|
2519
|
+
}
|
|
2520
|
+
const statusMap = { completed: 'done', blocked: 'blocked', failed: 'blocked_permanent' };
|
|
2521
|
+
const newStatus = statusMap[outcome];
|
|
2522
|
+
if (newStatus) toolUpdateTask(task_id, newStatus, summary || '');
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2206
2525
|
setListening(true);
|
|
2207
2526
|
|
|
2208
2527
|
// Check existing unconsumed messages first
|
|
@@ -2248,7 +2567,9 @@ async function toolListenCodex(from = null) {
|
|
|
2248
2567
|
const { messages: newMsgs, newOffset } = readNewMessages(lastReadOffset);
|
|
2249
2568
|
lastReadOffset = newOffset;
|
|
2250
2569
|
for (const msg of newMsgs) {
|
|
2251
|
-
if (
|
|
2570
|
+
if (consumed.has(msg.id)) continue;
|
|
2571
|
+
if (msg.to !== registeredName && msg.to !== '__group__' && msg.to !== '__all__') continue;
|
|
2572
|
+
if (msg.to === '__group__' && msg.from === registeredName) continue;
|
|
2252
2573
|
if (from && msg.from !== from && !msg.system) continue;
|
|
2253
2574
|
consumed.add(msg.id);
|
|
2254
2575
|
saveConsumedIds(registeredName, consumed);
|
|
@@ -2477,12 +2798,27 @@ function hashStagger(name) {
|
|
|
2477
2798
|
return 500 + (hash * 137) % 1000; // 0.5-1.5s range
|
|
2478
2799
|
}
|
|
2479
2800
|
|
|
2480
|
-
async function toolListenGroup() {
|
|
2801
|
+
async function toolListenGroup(outcome = null, task_id = null, summary = null) {
|
|
2481
2802
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
2482
2803
|
|
|
2804
|
+
// Outcome validation: update task state before entering the wait loop
|
|
2805
|
+
if (outcome && outcome !== 'in_progress' && task_id) {
|
|
2806
|
+
const taskList = getTasks();
|
|
2807
|
+
const task = taskList.find(t => t.id === task_id);
|
|
2808
|
+
if (!task) {
|
|
2809
|
+
return { error: true, message: `Invalid task_id "${task_id}" — task does not exist. Check list_tasks() and call listen_group() again with the correct task_id.` };
|
|
2810
|
+
}
|
|
2811
|
+
if (task.assignee && task.assignee !== registeredName) {
|
|
2812
|
+
return { error: true, message: `Task "${task_id}" is assigned to ${task.assignee}, not to you (${registeredName}). You cannot update another agent's task via listen_group().` };
|
|
2813
|
+
}
|
|
2814
|
+
const statusMap = { completed: 'done', blocked: 'blocked', failed: 'blocked_permanent' };
|
|
2815
|
+
const newStatus = statusMap[outcome];
|
|
2816
|
+
if (newStatus) toolUpdateTask(task_id, newStatus, summary || '');
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2483
2819
|
// Auto-detect direct mode and delegate to toolListen (prevents wrong-function bugs)
|
|
2484
2820
|
if (!isGroupMode() && !isManagedMode()) {
|
|
2485
|
-
return toolListen();
|
|
2821
|
+
return toolListen(null, null, null, null);
|
|
2486
2822
|
}
|
|
2487
2823
|
|
|
2488
2824
|
setListening(true);
|
|
@@ -2490,7 +2826,7 @@ async function toolListenGroup() {
|
|
|
2490
2826
|
const consumed = getConsumedIds(registeredName);
|
|
2491
2827
|
|
|
2492
2828
|
// Autonomous mode: cap listen at 30s — agents should use get_work() instead
|
|
2493
|
-
const autonomousTimeout = isAutonomousMode() ?
|
|
2829
|
+
const autonomousTimeout = isAutonomousMode() ? SERVER_CONFIG.AUTONOMOUS_LISTEN_MS : null;
|
|
2494
2830
|
const MAX_LISTEN_MS = 300000; // 5 minutes — MCP has no tool timeout, heartbeat keeps agent alive
|
|
2495
2831
|
const listenStart = Date.now();
|
|
2496
2832
|
|
|
@@ -2599,7 +2935,7 @@ async function toolListenGroup() {
|
|
|
2599
2935
|
});
|
|
2600
2936
|
chWatcher.on('error', () => {});
|
|
2601
2937
|
channelWatchers.push(chWatcher);
|
|
2602
|
-
} catch {}
|
|
2938
|
+
} catch (e) { log.debug("channel watcher setup failed:", e.message); }
|
|
2603
2939
|
}
|
|
2604
2940
|
}
|
|
2605
2941
|
} catch {
|
|
@@ -2638,6 +2974,72 @@ async function toolListenGroup() {
|
|
|
2638
2974
|
});
|
|
2639
2975
|
}
|
|
2640
2976
|
|
|
2977
|
+
// Auto speaker selection for group messages — determines who should respond
|
|
2978
|
+
// Priority: 1) @mentioned agents, 2) skill match, 3) round-robin fallback
|
|
2979
|
+
let _lastSpeakerIndex = 0;
|
|
2980
|
+
function selectSpeaker(msg, agentName, aliveAgentNames) {
|
|
2981
|
+
// 1. If explicitly addressed, those agents respond
|
|
2982
|
+
if (msg.addressed_to && msg.addressed_to.length > 0) {
|
|
2983
|
+
return msg.addressed_to.includes(agentName);
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
// 2. Direct messages — always respond
|
|
2987
|
+
if (msg.to === agentName) return true;
|
|
2988
|
+
|
|
2989
|
+
// 3. System messages — everyone sees, nobody needs to respond
|
|
2990
|
+
if (msg.system || msg.from === '__system__') return false;
|
|
2991
|
+
|
|
2992
|
+
// 4. Skill-based matching — check if message content matches agent's skills
|
|
2993
|
+
const cards = readJsonFile(AGENT_CARDS_FILE) || {};
|
|
2994
|
+
const myCard = cards[agentName];
|
|
2995
|
+
if (myCard && myCard.skills && myCard.skills.length > 0 && msg.content) {
|
|
2996
|
+
const contentLower = msg.content.toLowerCase();
|
|
2997
|
+
const hasSkillMatch = myCard.skills.some(skill => contentLower.includes(skill));
|
|
2998
|
+
if (hasSkillMatch) {
|
|
2999
|
+
// Check if OTHER agents also match — if multiple match, pick the best
|
|
3000
|
+
const otherMatchers = aliveAgentNames.filter(n => {
|
|
3001
|
+
if (n === agentName || n === msg.from) return false;
|
|
3002
|
+
const card = cards[n];
|
|
3003
|
+
return card && card.skills && card.skills.some(skill => contentLower.includes(skill));
|
|
3004
|
+
});
|
|
3005
|
+
// If this agent matches and has fewest other matchers, respond
|
|
3006
|
+
if (otherMatchers.length === 0) return true;
|
|
3007
|
+
// Multiple skill matches — first alphabetically gets priority (deterministic)
|
|
3008
|
+
const allMatchers = [agentName, ...otherMatchers].sort();
|
|
3009
|
+
return allMatchers[0] === agentName;
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
// 5. Round-robin fallback for unaddressed group messages
|
|
3014
|
+
const eligible = aliveAgentNames.filter(n => n !== msg.from).sort();
|
|
3015
|
+
if (eligible.length === 0) return false;
|
|
3016
|
+
const selectedIndex = _lastSpeakerIndex % eligible.length;
|
|
3017
|
+
const selected = eligible[selectedIndex] === agentName;
|
|
3018
|
+
if (selected) _lastSpeakerIndex++;
|
|
3019
|
+
return selected;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// Message priority classification: critical > normal > low
|
|
3023
|
+
// Critical: task assignments, human messages, workflow handoffs, system events
|
|
3024
|
+
// Normal: regular agent-to-agent chat
|
|
3025
|
+
// Low: status updates, acknowledgements
|
|
3026
|
+
function classifyPriority(msg) {
|
|
3027
|
+
if (msg.priority) return msg.priority; // explicit priority wins
|
|
3028
|
+
if (msg.from === '__user__') return 'critical';
|
|
3029
|
+
if (msg.system || msg.from === '__system__') {
|
|
3030
|
+
// System events about workflow/task are critical, others are normal
|
|
3031
|
+
if (msg.content && (msg.content.includes('[Workflow') || msg.content.includes('[TASK') || msg.content.includes('[APPROVAL'))) return 'critical';
|
|
3032
|
+
return 'normal';
|
|
3033
|
+
}
|
|
3034
|
+
if (msg.content) {
|
|
3035
|
+
const c = msg.content;
|
|
3036
|
+
if (c.includes('[Workflow') || c.includes('[HANDOFF]') || c.includes('[PLAN')) return 'critical';
|
|
3037
|
+
if (c.startsWith('[STATUS]') || c.startsWith('[ACK]') || c.startsWith('[PROGRESS]')) return 'low';
|
|
3038
|
+
}
|
|
3039
|
+
if (msg.type === 'handoff') return 'critical';
|
|
3040
|
+
return 'normal';
|
|
3041
|
+
}
|
|
3042
|
+
|
|
2641
3043
|
// Build the response for listen_group — kept lean to reduce context accumulation
|
|
2642
3044
|
// Context/history removed: agents should call get_history() when they need it
|
|
2643
3045
|
function buildListenGroupResponse(batch, consumed, agentName, listenStart) {
|
|
@@ -2648,12 +3050,16 @@ function buildListenGroupResponse(batch, consumed, agentName, listenStart) {
|
|
|
2648
3050
|
const wasAddressed = batch.some(m => m.addressed_to && m.addressed_to.includes(agentName));
|
|
2649
3051
|
sendLimit = wasAddressed ? 2 : 1;
|
|
2650
3052
|
|
|
2651
|
-
// Sort batch by priority:
|
|
3053
|
+
// Sort batch by priority: critical(0) > normal(1) > low(2), then by type
|
|
3054
|
+
const PRIORITY_ORDER = { critical: 0, normal: 1, low: 2 };
|
|
2652
3055
|
function messagePriority(m) {
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
3056
|
+
const prio = PRIORITY_ORDER[classifyPriority(m)] || 1;
|
|
3057
|
+
// Sub-sort within same priority: system > threaded > direct > broadcast
|
|
3058
|
+
let subPrio = 3;
|
|
3059
|
+
if (m.system || m.from === '__system__') subPrio = 0;
|
|
3060
|
+
else if (m.reply_to || m.thread_id) subPrio = 1;
|
|
3061
|
+
else if (!m.broadcast) subPrio = 2;
|
|
3062
|
+
return prio * 10 + subPrio;
|
|
2657
3063
|
}
|
|
2658
3064
|
batch.sort((a, b) => {
|
|
2659
3065
|
const pa = messagePriority(a), pb = messagePriority(b);
|
|
@@ -2686,7 +3092,7 @@ function buildListenGroupResponse(batch, consumed, agentName, listenStart) {
|
|
|
2686
3092
|
} else {
|
|
2687
3093
|
const lastListened = agents[n].last_listened_at;
|
|
2688
3094
|
const sinceLastListen = lastListened ? Date.now() - new Date(lastListened).getTime() : Infinity;
|
|
2689
|
-
agentStatus[n] = sinceLastListen >
|
|
3095
|
+
agentStatus[n] = sinceLastListen > SERVER_CONFIG.AGENT_UNRESPONSIVE_MS ? 'unresponsive' : 'working';
|
|
2690
3096
|
}
|
|
2691
3097
|
}
|
|
2692
3098
|
|
|
@@ -2697,6 +3103,7 @@ function buildListenGroupResponse(batch, consumed, agentName, listenStart) {
|
|
|
2697
3103
|
return {
|
|
2698
3104
|
id: m.id, from: m.from, to: m.to, content: m.content,
|
|
2699
3105
|
timestamp: m.timestamp,
|
|
3106
|
+
priority: classifyPriority(m),
|
|
2700
3107
|
age_seconds: ageSec,
|
|
2701
3108
|
...(ageSec > 30 && { delayed: true }),
|
|
2702
3109
|
...(m.reply_to && { reply_to: m.reply_to }),
|
|
@@ -2704,7 +3111,7 @@ function buildListenGroupResponse(batch, consumed, agentName, listenStart) {
|
|
|
2704
3111
|
...(m.addressed_to && { addressed_to: m.addressed_to }),
|
|
2705
3112
|
...(m.to === '__group__' && {
|
|
2706
3113
|
addressed_to_you: !m.addressed_to || m.addressed_to.includes(agentName),
|
|
2707
|
-
should_respond:
|
|
3114
|
+
should_respond: selectSpeaker(m, agentName, agentNames),
|
|
2708
3115
|
}),
|
|
2709
3116
|
};
|
|
2710
3117
|
}),
|
|
@@ -2744,11 +3151,21 @@ function buildListenGroupResponse(batch, consumed, agentName, listenStart) {
|
|
|
2744
3151
|
result.next_action = isAutonomousMode()
|
|
2745
3152
|
? 'Process these messages, then call get_work() to continue the proactive work loop. Do NOT call listen_group() — use get_work() instead.'
|
|
2746
3153
|
: 'After processing these messages and sending your response, call listen_group() again immediately. Never stop listening.';
|
|
3154
|
+
result.coordinator_mode = getConfig().coordinator_mode || 'responsive';
|
|
3155
|
+
|
|
3156
|
+
// Task reminder: remind agent of their outstanding tasks
|
|
3157
|
+
try {
|
|
3158
|
+
const myTasks = getTasks().filter(t => t.assignee === agentName && (t.status === 'pending' || t.status === 'in_progress'));
|
|
3159
|
+
if (myTasks.length > 0) {
|
|
3160
|
+
result.task_reminder = { pending: myTasks.filter(t => t.status === 'pending').length, in_progress: myTasks.filter(t => t.status === 'in_progress').length, tasks: myTasks.map(t => ({ id: t.id, title: t.title, status: t.status })) };
|
|
3161
|
+
}
|
|
3162
|
+
} catch (e) { log.debug('task reminder in listen_group failed:', e.message); }
|
|
3163
|
+
|
|
2747
3164
|
return result;
|
|
2748
3165
|
}
|
|
2749
3166
|
|
|
2750
3167
|
function toolGetHistory(limit = 50, thread_id = null) {
|
|
2751
|
-
limit = Math.min(Math.max(1, limit ||
|
|
3168
|
+
limit = Math.min(Math.max(1, limit || SERVER_CONFIG.HISTORY_LIMIT_DEFAULT), SERVER_CONFIG.HISTORY_LIMIT_MAX);
|
|
2752
3169
|
// Tail-read with 2x buffer to account for filtering reducing results
|
|
2753
3170
|
let history = tailReadJsonl(getHistoryFile(currentBranch), limit * 2);
|
|
2754
3171
|
if (thread_id) {
|
|
@@ -3020,6 +3437,10 @@ function toolCreateTask(title, description = '', assignee = null) {
|
|
|
3020
3437
|
saveTasks(tasks);
|
|
3021
3438
|
touchActivity();
|
|
3022
3439
|
|
|
3440
|
+
// Broadcast task creation event
|
|
3441
|
+
const assigneeLabel = task.assignee ? `, assigned to ${task.assignee}` : '';
|
|
3442
|
+
broadcastSystemMessage(`[EVENT] Task "${task.title}" created by ${registeredName}${assigneeLabel}`, registeredName);
|
|
3443
|
+
|
|
3023
3444
|
const result = { success: true, task_id: task.id, assignee: task.assignee };
|
|
3024
3445
|
if (taskChannel) result.channel = taskChannel;
|
|
3025
3446
|
return result;
|
|
@@ -3066,9 +3487,52 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
3066
3487
|
return { success: true, task_id: task.id, status: 'blocked_permanent', circuit_breaker: true, message: 'Task permanently blocked — too many agents failed. Needs human review.' };
|
|
3067
3488
|
}
|
|
3068
3489
|
|
|
3490
|
+
// Review gate: block 'done' if a quality/reviewer agent is online and no approved review exists
|
|
3491
|
+
if (status === 'done') {
|
|
3492
|
+
const agents = getAgents();
|
|
3493
|
+
const profiles = getProfiles();
|
|
3494
|
+
const hasReviewer = Object.keys(agents).some(n => {
|
|
3495
|
+
if (n === registeredName) return false;
|
|
3496
|
+
if (!isPidAlive(agents[n].pid, agents[n].last_activity)) return false;
|
|
3497
|
+
const role = (profiles[n] && profiles[n].role) || '';
|
|
3498
|
+
return role === 'quality' || role === 'reviewer';
|
|
3499
|
+
});
|
|
3500
|
+
if (hasReviewer) {
|
|
3501
|
+
const reviews = getReviews();
|
|
3502
|
+
const hasApproval = reviews.some(r =>
|
|
3503
|
+
r.status === 'approved' &&
|
|
3504
|
+
r.requested_by === registeredName &&
|
|
3505
|
+
(r.file && task.title && (task.title === r.file || task.title.includes(r.file)))
|
|
3506
|
+
);
|
|
3507
|
+
if (!hasApproval) {
|
|
3508
|
+
const reviewId = 'review_' + generateId();
|
|
3509
|
+
reviews.push({
|
|
3510
|
+
id: reviewId,
|
|
3511
|
+
file: task.title,
|
|
3512
|
+
requested_by: registeredName,
|
|
3513
|
+
status: 'pending',
|
|
3514
|
+
requested_at: new Date().toISOString(),
|
|
3515
|
+
});
|
|
3516
|
+
writeJsonFile(REVIEWS_FILE, reviews);
|
|
3517
|
+
task.status = 'in_review';
|
|
3518
|
+
task.updated_at = new Date().toISOString();
|
|
3519
|
+
saveTasks(tasks);
|
|
3520
|
+
broadcastSystemMessage(`[REVIEW GATE] ${registeredName} tried to mark "${task.title}" done but no review exists. Auto-created review ${reviewId}. A reviewer must approve before this task can be completed.`, registeredName);
|
|
3521
|
+
logViolation('review_gate_blocked', registeredName, `Task "${task.title}" (${task.id}) blocked — no approved review. Auto-created ${reviewId}.`);
|
|
3522
|
+
touchActivity();
|
|
3523
|
+
return {
|
|
3524
|
+
blocked: true,
|
|
3525
|
+
task_id: task.id,
|
|
3526
|
+
status: 'in_review',
|
|
3527
|
+
review_id: reviewId,
|
|
3528
|
+
message: `Cannot mark done — a reviewer is online and no approval exists. Review ${reviewId} auto-created. Wait for approval, then try again.`,
|
|
3529
|
+
};
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3069
3534
|
task.status = status;
|
|
3070
3535
|
task.updated_at = new Date().toISOString();
|
|
3071
|
-
// Clear escalation flag when task is unblocked
|
|
3072
3536
|
if (status !== 'blocked' && task.escalated_at) delete task.escalated_at;
|
|
3073
3537
|
if (notes) {
|
|
3074
3538
|
task.notes.push({ by: registeredName, text: notes, at: new Date().toISOString() });
|
|
@@ -3086,7 +3550,7 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
3086
3550
|
} else if (status === 'blocked') {
|
|
3087
3551
|
saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `BLOCKED on: ${task.title}`, _status_since: new Date().toISOString() }));
|
|
3088
3552
|
}
|
|
3089
|
-
} catch {}
|
|
3553
|
+
} catch (e) { log.warn("verify_and_advance failed:", e.message); }
|
|
3090
3554
|
|
|
3091
3555
|
// Task-channel auto-join: when claiming a task that has a channel, auto-join it
|
|
3092
3556
|
if (status === 'in_progress' && task.channel) {
|
|
@@ -3100,6 +3564,7 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
3100
3564
|
// Event hooks: task completion
|
|
3101
3565
|
if (status === 'done') {
|
|
3102
3566
|
fireEvent('task_complete', { title: task.title, created_by: task.created_by });
|
|
3567
|
+
appendNotification('task_done', registeredName, `Task "${task.title}" completed by ${registeredName}`, task.id);
|
|
3103
3568
|
// Check if this resolves any dependencies
|
|
3104
3569
|
const deps = getDeps();
|
|
3105
3570
|
for (const dep of deps) {
|
|
@@ -3128,6 +3593,50 @@ function toolUpdateTask(taskId, status, notes = null) {
|
|
|
3128
3593
|
if (aliveOthers.length > 0) {
|
|
3129
3594
|
broadcastSystemMessage(`[REVIEW NEEDED] ${registeredName} completed task "${task.title}". Team: please review the work and call submit_review() if applicable.`, registeredName);
|
|
3130
3595
|
}
|
|
3596
|
+
|
|
3597
|
+
// Auto-sync: advance matching workflow step when task is done
|
|
3598
|
+
try {
|
|
3599
|
+
const workflows = getWorkflows();
|
|
3600
|
+
let wfChanged = false;
|
|
3601
|
+
for (const wf of workflows) {
|
|
3602
|
+
if (wf.status !== 'active') continue;
|
|
3603
|
+
for (const step of wf.steps) {
|
|
3604
|
+
if (step.status !== 'in_progress') continue;
|
|
3605
|
+
if (step.assignee !== registeredName) continue;
|
|
3606
|
+
// Match by assignee — the agent who completed the task also has an in_progress step
|
|
3607
|
+
step.status = 'done';
|
|
3608
|
+
step.completed_at = new Date().toISOString();
|
|
3609
|
+
step.notes = `Auto-completed via task "${task.title}"`;
|
|
3610
|
+
saveWorkflowCheckpoint(wf, step);
|
|
3611
|
+
// Start next ready steps
|
|
3612
|
+
const nextSteps = findReadySteps(wf);
|
|
3613
|
+
for (const ns of nextSteps) {
|
|
3614
|
+
if (ns.requires_approval) {
|
|
3615
|
+
ns.status = 'awaiting_approval';
|
|
3616
|
+
ns.approval_requested_at = new Date().toISOString();
|
|
3617
|
+
sendSystemMessage('__user__', `[APPROVAL NEEDED] Workflow "${wf.name}" — Step ${ns.id}: "${ns.description}". Approve or reject from the dashboard.`);
|
|
3618
|
+
} else {
|
|
3619
|
+
ns.status = 'in_progress';
|
|
3620
|
+
ns.started_at = new Date().toISOString();
|
|
3621
|
+
if (ns.assignee && ns.assignee !== registeredName) {
|
|
3622
|
+
const handoffContent = `[Workflow "${wf.name}"] Step ${ns.id} assigned to you: ${ns.description}`;
|
|
3623
|
+
messageSeq++;
|
|
3624
|
+
const hMsg = { id: generateId(), seq: messageSeq, from: registeredName, to: ns.assignee, content: handoffContent, timestamp: new Date().toISOString(), type: 'handoff' };
|
|
3625
|
+
fs.appendFileSync(getMessagesFile(currentBranch), JSON.stringify(hMsg) + '\n');
|
|
3626
|
+
fs.appendFileSync(getHistoryFile(currentBranch), JSON.stringify(hMsg) + '\n');
|
|
3627
|
+
}
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
if (wf.steps.every(s => s.status === 'done')) wf.status = 'completed';
|
|
3631
|
+
wf.updated_at = new Date().toISOString();
|
|
3632
|
+
wfChanged = true;
|
|
3633
|
+
broadcastSystemMessage(`[WORKFLOW] Step "${step.description}" auto-advanced via task completion by ${registeredName}`);
|
|
3634
|
+
break; // one step per task completion
|
|
3635
|
+
}
|
|
3636
|
+
if (wfChanged) break;
|
|
3637
|
+
}
|
|
3638
|
+
if (wfChanged) saveWorkflows(workflows);
|
|
3639
|
+
} catch (e) { log.warn('auto-advance workflow on task done failed:', e.message); }
|
|
3131
3640
|
}
|
|
3132
3641
|
|
|
3133
3642
|
return { success: true, task_id: task.id, status: task.status, title: task.title };
|
|
@@ -3149,7 +3658,7 @@ function toolListTasks(status = null, assignee = null) {
|
|
|
3149
3658
|
created_by: t.created_by,
|
|
3150
3659
|
created_at: t.created_at,
|
|
3151
3660
|
updated_at: t.updated_at,
|
|
3152
|
-
notes_count: t.notes.length,
|
|
3661
|
+
notes_count: Array.isArray(t.notes) ? t.notes.length : 0,
|
|
3153
3662
|
})),
|
|
3154
3663
|
};
|
|
3155
3664
|
}
|
|
@@ -3203,7 +3712,7 @@ function toolSearchMessages(query, from = null, limit = 20) {
|
|
|
3203
3712
|
allMessages = allMessages.concat(chMsgs);
|
|
3204
3713
|
}
|
|
3205
3714
|
}
|
|
3206
|
-
} catch {}
|
|
3715
|
+
} catch (e) { log.warn("get_work search failed:", e.message); }
|
|
3207
3716
|
// Sort by timestamp descending for newest-first results
|
|
3208
3717
|
allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
3209
3718
|
|
|
@@ -3233,7 +3742,7 @@ function toolSearchMessages(query, from = null, limit = 20) {
|
|
|
3233
3742
|
allMessages = allMessages.concat(readJsonl(chFile));
|
|
3234
3743
|
}
|
|
3235
3744
|
}
|
|
3236
|
-
} catch {}
|
|
3745
|
+
} catch (e) { log.debug("get_work detail failed:", e.message); }
|
|
3237
3746
|
allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
|
3238
3747
|
for (let i = 0; i < allMessages.length && results.length < limit; i++) {
|
|
3239
3748
|
const m = allMessages[i];
|
|
@@ -3411,7 +3920,8 @@ function toolCreateWorkflow(name, steps, autonomous = false, parallel = false) {
|
|
|
3411
3920
|
description: step.description.substring(0, 200),
|
|
3412
3921
|
assignee: step.assignee || null,
|
|
3413
3922
|
depends_on: Array.isArray(step.depends_on) ? step.depends_on : [],
|
|
3414
|
-
|
|
3923
|
+
requires_approval: !!step.requires_approval,
|
|
3924
|
+
status: 'pending',
|
|
3415
3925
|
started_at: null,
|
|
3416
3926
|
completed_at: null,
|
|
3417
3927
|
notes: '',
|
|
@@ -3500,11 +4010,37 @@ function toolAdvanceWorkflow(workflowId, notes) {
|
|
|
3500
4010
|
currentStep.completed_at = new Date().toISOString();
|
|
3501
4011
|
if (notes) currentStep.notes = notes.substring(0, 500);
|
|
3502
4012
|
|
|
4013
|
+
// Save checkpoint
|
|
4014
|
+
saveWorkflowCheckpoint(wf, currentStep);
|
|
4015
|
+
|
|
4016
|
+
// Auto-sync: mark matching in_progress tasks as done
|
|
4017
|
+
try {
|
|
4018
|
+
const tasks = getTasks();
|
|
4019
|
+
const matchingTask = tasks.find(t =>
|
|
4020
|
+
t.status === 'in_progress' && t.assignee === registeredName
|
|
4021
|
+
);
|
|
4022
|
+
if (matchingTask) {
|
|
4023
|
+
matchingTask.status = 'done';
|
|
4024
|
+
matchingTask.updated_at = new Date().toISOString();
|
|
4025
|
+
matchingTask.notes.push({ by: '__system__', text: `Auto-completed via workflow step "${currentStep.description}"`, at: new Date().toISOString() });
|
|
4026
|
+
saveTasks(tasks);
|
|
4027
|
+
}
|
|
4028
|
+
} catch (e) { log.warn('auto-complete task on workflow advance failed:', e.message); }
|
|
4029
|
+
|
|
3503
4030
|
// Find all ready steps (supports parallel via depends_on)
|
|
3504
4031
|
const nextSteps = findReadySteps(wf);
|
|
3505
4032
|
if (nextSteps.length > 0) {
|
|
3506
4033
|
const agents = getAgents();
|
|
3507
4034
|
for (const step of nextSteps) {
|
|
4035
|
+
// Check if step requires human approval before starting
|
|
4036
|
+
if (step.requires_approval) {
|
|
4037
|
+
step.status = 'awaiting_approval';
|
|
4038
|
+
step.approval_requested_at = new Date().toISOString();
|
|
4039
|
+
sendSystemMessage('__user__',
|
|
4040
|
+
`[APPROVAL NEEDED] Workflow "${wf.name}" — Step ${step.id}: "${step.description}". Approve or reject from the dashboard.`
|
|
4041
|
+
);
|
|
4042
|
+
continue;
|
|
4043
|
+
}
|
|
3508
4044
|
step.status = 'in_progress';
|
|
3509
4045
|
step.started_at = new Date().toISOString();
|
|
3510
4046
|
if (step.assignee && agents[step.assignee] && step.assignee !== registeredName && canSendTo(registeredName, step.assignee)) {
|
|
@@ -3524,6 +4060,7 @@ function toolAdvanceWorkflow(workflowId, notes) {
|
|
|
3524
4060
|
|
|
3525
4061
|
const doneCount = wf.steps.filter(s => s.status === 'done').length;
|
|
3526
4062
|
const pct = Math.round((doneCount / wf.steps.length) * 100);
|
|
4063
|
+
appendNotification('workflow_advanced', registeredName, `Workflow "${wf.name}" step ${currentStep.id} done (${pct}%)`, wf.id);
|
|
3527
4064
|
|
|
3528
4065
|
return {
|
|
3529
4066
|
success: true,
|
|
@@ -3535,14 +4072,32 @@ function toolAdvanceWorkflow(workflowId, notes) {
|
|
|
3535
4072
|
};
|
|
3536
4073
|
}
|
|
3537
4074
|
|
|
3538
|
-
function toolWorkflowStatus(workflowId) {
|
|
4075
|
+
function toolWorkflowStatus(workflowId, action, checkpointIndex) {
|
|
3539
4076
|
const workflows = getWorkflows();
|
|
4077
|
+
|
|
4078
|
+
// Rollback action
|
|
4079
|
+
if (action === 'rollback' && workflowId && checkpointIndex !== undefined) {
|
|
4080
|
+
const wf = workflows.find(w => w.id === workflowId);
|
|
4081
|
+
if (!wf) return { error: `Workflow not found: ${workflowId}` };
|
|
4082
|
+
if (!wf.checkpoints || !wf.checkpoints[checkpointIndex]) return { error: 'Checkpoint not found' };
|
|
4083
|
+
const checkpoint = wf.checkpoints[checkpointIndex];
|
|
4084
|
+
for (const savedStep of checkpoint.step_states) {
|
|
4085
|
+
const step = wf.steps.find(s => s.id === savedStep.id);
|
|
4086
|
+
if (step) { step.status = savedStep.status; step.assignee = savedStep.assignee; }
|
|
4087
|
+
}
|
|
4088
|
+
wf.updated_at = new Date().toISOString();
|
|
4089
|
+
saveWorkflows(workflows);
|
|
4090
|
+
broadcastSystemMessage(`[WORKFLOW] Rolled back "${wf.name}" to checkpoint: step "${checkpoint.step_description}"`);
|
|
4091
|
+
return { success: true, rolled_back_to: checkpoint };
|
|
4092
|
+
}
|
|
4093
|
+
|
|
3540
4094
|
if (workflowId) {
|
|
3541
4095
|
const wf = workflows.find(w => w.id === workflowId);
|
|
3542
4096
|
if (!wf) return { error: `Workflow not found: ${workflowId}` };
|
|
3543
4097
|
const doneCount = wf.steps.filter(s => s.status === 'done').length;
|
|
3544
4098
|
const pct = Math.round((doneCount / wf.steps.length) * 100);
|
|
3545
4099
|
const result = { workflow: wf, progress: `${doneCount}/${wf.steps.length} (${pct}%)` };
|
|
4100
|
+
if (wf.checkpoints) result.checkpoints = wf.checkpoints.length;
|
|
3546
4101
|
if (wf.status === 'completed') result.report = generateCompletionReport(wf);
|
|
3547
4102
|
return result;
|
|
3548
4103
|
}
|
|
@@ -3550,7 +4105,7 @@ function toolWorkflowStatus(workflowId) {
|
|
|
3550
4105
|
count: workflows.length,
|
|
3551
4106
|
workflows: workflows.map(w => {
|
|
3552
4107
|
const doneCount = w.steps.filter(s => s.status === 'done').length;
|
|
3553
|
-
return { id: w.id, name: w.name, status: w.status, steps: w.steps.length, done: doneCount, progress: Math.round((doneCount / w.steps.length) * 100) + '%' };
|
|
4108
|
+
return { id: w.id, name: w.name, status: w.status, steps: w.steps.length, done: doneCount, progress: Math.round((doneCount / w.steps.length) * 100) + '%', checkpoints: w.checkpoints ? w.checkpoints.length : 0 };
|
|
3554
4109
|
}),
|
|
3555
4110
|
};
|
|
3556
4111
|
}
|
|
@@ -3880,7 +4435,8 @@ async function toolVerifyAndAdvance(params) {
|
|
|
3880
4435
|
// AUTO-ADVANCE
|
|
3881
4436
|
currentStep.status = 'done';
|
|
3882
4437
|
currentStep.completed_at = new Date().toISOString();
|
|
3883
|
-
|
|
4438
|
+
saveWorkflowCheckpoint(wf, currentStep);
|
|
4439
|
+
clearCheckpoint(registeredName, workflow_id, currentStep.id);
|
|
3884
4440
|
return advanceToNextSteps(false);
|
|
3885
4441
|
}
|
|
3886
4442
|
|
|
@@ -3888,6 +4444,7 @@ async function toolVerifyAndAdvance(params) {
|
|
|
3888
4444
|
// ADVANCE BUT FLAG
|
|
3889
4445
|
currentStep.status = 'done';
|
|
3890
4446
|
currentStep.completed_at = new Date().toISOString();
|
|
4447
|
+
saveWorkflowCheckpoint(wf, currentStep);
|
|
3891
4448
|
currentStep.flagged = true;
|
|
3892
4449
|
currentStep.flag_reason = `Low confidence (${confidence}%). May need review later.`;
|
|
3893
4450
|
clearCheckpoint(registeredName, workflow_id, currentStep.id); // Item 8: clear checkpoint
|
|
@@ -4057,51 +4614,192 @@ function reassignWorkFrom(deadAgentName) {
|
|
|
4057
4614
|
return reassignCount;
|
|
4058
4615
|
}
|
|
4059
4616
|
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
if (!
|
|
4063
|
-
|
|
4064
|
-
|
|
4617
|
+
// Auto-reassign workflow steps from dead agents after timeout
|
|
4618
|
+
function checkStuckWorkflowSteps() {
|
|
4619
|
+
if (!registeredName) return;
|
|
4620
|
+
const workflows = getWorkflows();
|
|
4065
4621
|
const agents = getAgents();
|
|
4066
|
-
const
|
|
4067
|
-
let
|
|
4068
|
-
|
|
4069
|
-
for (const [name, agent] of Object.entries(agents)) {
|
|
4070
|
-
if (name === registeredName) continue;
|
|
4071
|
-
if (!isPidAlive(agent.pid, agent.last_activity)) continue;
|
|
4622
|
+
const timeoutMs = (parseInt(process.env.NEOHIVE_STEP_TIMEOUT_MINUTES) || 5) * 60000;
|
|
4623
|
+
let changed = false;
|
|
4072
4624
|
|
|
4073
|
-
|
|
4625
|
+
for (const wf of workflows) {
|
|
4626
|
+
if (wf.status !== 'active') continue;
|
|
4627
|
+
if (wf.paused) continue;
|
|
4074
4628
|
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4629
|
+
for (const step of wf.steps) {
|
|
4630
|
+
if (step.status !== 'in_progress') continue;
|
|
4631
|
+
if (!step.assignee) continue;
|
|
4632
|
+
if (!step.started_at) continue;
|
|
4633
|
+
|
|
4634
|
+
const elapsed = Date.now() - new Date(step.started_at).getTime();
|
|
4635
|
+
if (elapsed < timeoutMs) continue;
|
|
4636
|
+
|
|
4637
|
+
const agentInfo = agents[step.assignee];
|
|
4638
|
+
if (agentInfo && isPidAlive(agentInfo.pid, agentInfo.last_activity)) continue;
|
|
4639
|
+
|
|
4640
|
+
log.warn(`Workflow step ${step.id} reassigned: ${step.assignee} offline for ${Math.round(elapsed / 60000)}min`);
|
|
4641
|
+
const deadAgent = step.assignee;
|
|
4642
|
+
step.status = 'pending';
|
|
4643
|
+
step.assignee = null;
|
|
4644
|
+
step.reassigned_from = deadAgent;
|
|
4645
|
+
step.reassigned_at = new Date().toISOString();
|
|
4646
|
+
changed = true;
|
|
4084
4647
|
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
sendSystemMessage(name,
|
|
4088
|
-
`[WATCHDOG] You've been idle for ${Math.round(idleTime / 60000)} minutes. Call get_work() NOW or your work will be reassigned.`
|
|
4648
|
+
broadcastSystemMessage(
|
|
4649
|
+
`[WORKFLOW] Step "${step.description}" reassigned — ${deadAgent} went offline. Next available agent will pick it up via get_work().`
|
|
4089
4650
|
);
|
|
4090
|
-
agent.watchdog_hard_nudged = now;
|
|
4091
|
-
agentsChanged = true;
|
|
4092
|
-
}
|
|
4093
|
-
|
|
4094
|
-
// IDLE > 10 minutes: reassign their work
|
|
4095
|
-
if (idleTime > 600000 && !agent.watchdog_reassigned) {
|
|
4096
|
-
const count = reassignWorkFrom(name);
|
|
4097
|
-
broadcastSystemMessage(`[WATCHDOG] ${name} has been unresponsive for 10+ minutes. ${count} task(s) reassigned.`);
|
|
4098
|
-
agent.watchdog_reassigned = now;
|
|
4099
|
-
agentsChanged = true;
|
|
4100
4651
|
}
|
|
4101
4652
|
}
|
|
4102
4653
|
|
|
4103
|
-
|
|
4104
|
-
|
|
4654
|
+
if (changed) saveWorkflows(workflows);
|
|
4655
|
+
}
|
|
4656
|
+
|
|
4657
|
+
// Stale task detection: warn about tasks in_progress for >30 minutes without update
|
|
4658
|
+
const _staleTaskWarned = new Set();
|
|
4659
|
+
function checkStaleTasks() {
|
|
4660
|
+
try {
|
|
4661
|
+
const tasks = getTasks();
|
|
4662
|
+
const staleThresholdMs = 30 * 60 * 1000; // 30 minutes
|
|
4663
|
+
const now = Date.now();
|
|
4664
|
+
for (const task of tasks) {
|
|
4665
|
+
if (task.status !== 'in_progress') continue;
|
|
4666
|
+
if (!task.updated_at) continue;
|
|
4667
|
+
const elapsed = now - new Date(task.updated_at).getTime();
|
|
4668
|
+
if (elapsed < staleThresholdMs) continue;
|
|
4669
|
+
if (_staleTaskWarned.has(task.id)) continue;
|
|
4670
|
+
_staleTaskWarned.add(task.id);
|
|
4671
|
+
const mins = Math.round(elapsed / 60000);
|
|
4672
|
+
broadcastSystemMessage(`[WARNING] Stale task: "${task.title}" assigned to ${task.assignee || 'unassigned'} — in_progress for ${mins}min without update. Agent should call update_task("${task.id}", "done") or report a blocker.`);
|
|
4673
|
+
log.warn(`Stale task detected: ${task.id} "${task.title}" (${mins}min)`);
|
|
4674
|
+
}
|
|
4675
|
+
} catch (e) { log.debug('stale task check failed:', e.message); }
|
|
4676
|
+
}
|
|
4677
|
+
|
|
4678
|
+
// Self-healing watchdog: silently reclaim stale in_progress tasks from dead/idle agents.
|
|
4679
|
+
// Runs at most once per 60s (throttled inside the 10s heartbeat loop).
|
|
4680
|
+
// retry_count < 3 → strip assignee + reset to pending (next agent picks it up via get_work)
|
|
4681
|
+
// retry_count >= 3 → mark blocked_permanent + wake coordinator
|
|
4682
|
+
let _lastSelfHealRun = 0;
|
|
4683
|
+
function selfHealingWatchdog() {
|
|
4684
|
+
const now = Date.now();
|
|
4685
|
+
if (now - _lastSelfHealRun < 60000) return;
|
|
4686
|
+
_lastSelfHealRun = now;
|
|
4687
|
+
|
|
4688
|
+
try {
|
|
4689
|
+
const tasks = getTasks();
|
|
4690
|
+
const agents = getAgents();
|
|
4691
|
+
const IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
|
|
4692
|
+
const POISON_PILL_COUNT = 3;
|
|
4693
|
+
|
|
4694
|
+
let changed = false;
|
|
4695
|
+
const reclaimed = [];
|
|
4696
|
+
const poisoned = [];
|
|
4697
|
+
|
|
4698
|
+
for (const task of tasks) {
|
|
4699
|
+
if (task.status !== 'in_progress') continue;
|
|
4700
|
+
if (!task.assignee) continue;
|
|
4701
|
+
|
|
4702
|
+
const assignee = agents[task.assignee];
|
|
4703
|
+
// Only reclaim if the assignee is definitively dead (PID gone + heartbeat stale)
|
|
4704
|
+
if (assignee && isPidAlive(assignee.pid, assignee.last_activity)) continue;
|
|
4705
|
+
// Also reclaim if assignee entry missing entirely (agent never re-registered)
|
|
4706
|
+
const lastActivity = assignee ? new Date(assignee.last_activity).getTime() : 0;
|
|
4707
|
+
if (assignee && (now - lastActivity) < IDLE_THRESHOLD_MS) continue;
|
|
4708
|
+
|
|
4709
|
+
const retryCount = (task.retry_count || 0) + 1;
|
|
4710
|
+
task.retry_count = retryCount;
|
|
4711
|
+
task.updated_at = new Date().toISOString();
|
|
4712
|
+
|
|
4713
|
+
if (retryCount >= POISON_PILL_COUNT) {
|
|
4714
|
+
// Poison pill: task has been abandoned too many times — escalate
|
|
4715
|
+
task.status = 'blocked_permanent';
|
|
4716
|
+
task.blocked_reason = `Abandoned ${retryCount} times by agents (last: ${task.assignee}). Needs coordinator intervention.`;
|
|
4717
|
+
task.assignee = null;
|
|
4718
|
+
poisoned.push(task);
|
|
4719
|
+
} else {
|
|
4720
|
+
// Normal self-heal: reset to pending for next available agent
|
|
4721
|
+
const prevAssignee = task.assignee;
|
|
4722
|
+
task.assignee = null;
|
|
4723
|
+
task.status = 'pending';
|
|
4724
|
+
reclaimed.push({ task, prevAssignee });
|
|
4725
|
+
}
|
|
4726
|
+
changed = true;
|
|
4727
|
+
}
|
|
4728
|
+
|
|
4729
|
+
if (!changed) return;
|
|
4730
|
+
saveTasks(tasks);
|
|
4731
|
+
|
|
4732
|
+
// Notify team about reclaimed tasks (one broadcast)
|
|
4733
|
+
if (reclaimed.length > 0) {
|
|
4734
|
+
const names = reclaimed.map(r => `"${r.task.title}" (was: ${r.prevAssignee})`).join(', ');
|
|
4735
|
+
broadcastSystemMessage(`[WATCHDOG] ${reclaimed.length} stale task(s) reset to pending: ${names}. Call get_work() to claim.`);
|
|
4736
|
+
log.info(`[self-heal] Reclaimed ${reclaimed.length} task(s): ${reclaimed.map(r => r.task.id).join(', ')}`);
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4739
|
+
// Wake coordinator for poison-pill tasks
|
|
4740
|
+
if (poisoned.length > 0) {
|
|
4741
|
+
const profiles = readJsonFileSafe(PROFILES_FILE, {});
|
|
4742
|
+
const lead = Object.entries(agents).find(([n, a]) =>
|
|
4743
|
+
isPidAlive(a.pid, a.last_activity) && profiles[n] && (profiles[n].role === 'lead' || profiles[n].role === 'coordinator')
|
|
4744
|
+
);
|
|
4745
|
+
const leadName = lead ? lead[0] : null;
|
|
4746
|
+
const taskList = poisoned.map(t => `"${t.title}" (${t.id})`).join(', ');
|
|
4747
|
+
const msg = `[WATCHDOG] POISON PILL: ${poisoned.length} task(s) abandoned ${POISON_PILL_COUNT}+ times and marked blocked_permanent: ${taskList}. Manual intervention required.`;
|
|
4748
|
+
if (leadName) {
|
|
4749
|
+
sendSystemMessage(leadName, msg);
|
|
4750
|
+
} else {
|
|
4751
|
+
broadcastSystemMessage(msg);
|
|
4752
|
+
}
|
|
4753
|
+
log.warn(`[self-heal] Poison pill tasks: ${poisoned.map(t => t.id).join(', ')}`);
|
|
4754
|
+
}
|
|
4755
|
+
} catch (e) { log.warn('[self-heal] watchdog error:', e.message); }
|
|
4756
|
+
}
|
|
4757
|
+
|
|
4758
|
+
function watchdogCheck() {
|
|
4759
|
+
// Run in autonomous mode always, AND in group mode when agents are idle 5+ min
|
|
4760
|
+
if (!isAutonomousMode() && !isGroupMode()) return;
|
|
4761
|
+
if (!amIWatchdog()) return;
|
|
4762
|
+
|
|
4763
|
+
const agents = getAgents();
|
|
4764
|
+
const now = Date.now();
|
|
4765
|
+
let agentsChanged = false;
|
|
4766
|
+
|
|
4767
|
+
for (const [name, agent] of Object.entries(agents)) {
|
|
4768
|
+
if (name === registeredName) continue;
|
|
4769
|
+
if (!isPidAlive(agent.pid, agent.last_activity)) continue;
|
|
4770
|
+
|
|
4771
|
+
const idleTime = now - new Date(agent.last_activity).getTime();
|
|
4772
|
+
|
|
4773
|
+
// IDLE > 2 minutes: nudge
|
|
4774
|
+
if (idleTime > 120000 && !agent.watchdog_nudged) {
|
|
4775
|
+
sendSystemMessage(name,
|
|
4776
|
+
`[WATCHDOG] You've been idle for ${Math.round(idleTime / 60000)} minutes. Call get_work() to find your next task. Never be idle.`
|
|
4777
|
+
);
|
|
4778
|
+
trackReputation(name, 'watchdog_nudge');
|
|
4779
|
+
agent.watchdog_nudged = now;
|
|
4780
|
+
agentsChanged = true;
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4783
|
+
// IDLE > 5 minutes: stronger nudge
|
|
4784
|
+
if (idleTime > 300000 && !agent.watchdog_hard_nudged) {
|
|
4785
|
+
sendSystemMessage(name,
|
|
4786
|
+
`[WATCHDOG] You've been idle for ${Math.round(idleTime / 60000)} minutes. Call get_work() NOW or your work will be reassigned.`
|
|
4787
|
+
);
|
|
4788
|
+
agent.watchdog_hard_nudged = now;
|
|
4789
|
+
agentsChanged = true;
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
// IDLE > 10 minutes: reassign their work
|
|
4793
|
+
if (idleTime > 600000 && !agent.watchdog_reassigned) {
|
|
4794
|
+
const count = reassignWorkFrom(name);
|
|
4795
|
+
broadcastSystemMessage(`[WATCHDOG] ${name} has been unresponsive for 10+ minutes. ${count} task(s) reassigned.`);
|
|
4796
|
+
agent.watchdog_reassigned = now;
|
|
4797
|
+
agentsChanged = true;
|
|
4798
|
+
}
|
|
4799
|
+
}
|
|
4800
|
+
|
|
4801
|
+
// Check for stuck workflow steps
|
|
4802
|
+
const workflows = getWorkflows();
|
|
4105
4803
|
let workflowsChanged = false;
|
|
4106
4804
|
for (const wf of workflows) {
|
|
4107
4805
|
if (wf.status !== 'active') continue;
|
|
@@ -4160,7 +4858,7 @@ function watchdogCheck() {
|
|
|
4160
4858
|
sendSystemMessage(worker, `[REBALANCE] You've been moved from ${quietTeam.name} to ${busyTeam.name} — they have ${busyTeam.pendingTasks} pending tasks and need help.`);
|
|
4161
4859
|
}
|
|
4162
4860
|
}
|
|
4163
|
-
} catch {}
|
|
4861
|
+
} catch (e) { log.warn("escalate blocked tasks failed:", e.message); }
|
|
4164
4862
|
|
|
4165
4863
|
// UE5 safety: detect stale UE5 locks (ue5-editor, ue5-compile)
|
|
4166
4864
|
try {
|
|
@@ -4185,7 +4883,7 @@ function watchdogCheck() {
|
|
|
4185
4883
|
}
|
|
4186
4884
|
}
|
|
4187
4885
|
if (locksChanged) writeJsonFile(LOCKS_FILE, locks);
|
|
4188
|
-
} catch {}
|
|
4886
|
+
} catch (e) { log.warn("stale lock cleanup failed:", e.message); }
|
|
4189
4887
|
|
|
4190
4888
|
if (agentsChanged) saveAgents(agents);
|
|
4191
4889
|
if (workflowsChanged) saveWorkflows(workflows);
|
|
@@ -4407,7 +5105,7 @@ function generateCompletionReport(workflow) {
|
|
|
4407
5105
|
totalRetries += relevant.length;
|
|
4408
5106
|
for (const r of relevant) retryDetails.push({ agent: name, task: r.task, attempt: r.attempt });
|
|
4409
5107
|
}
|
|
4410
|
-
} catch {}
|
|
5108
|
+
} catch (e) { log.debug("auto-plan retry scan failed:", e.message); }
|
|
4411
5109
|
}
|
|
4412
5110
|
|
|
4413
5111
|
const report = {
|
|
@@ -4585,7 +5283,7 @@ function autoAssignRoles() {
|
|
|
4585
5283
|
}
|
|
4586
5284
|
}
|
|
4587
5285
|
saveChannelsData(channels);
|
|
4588
|
-
} catch {}
|
|
5286
|
+
} catch (e) { log.warn("stale channel cleanup failed:", e.message); }
|
|
4589
5287
|
}
|
|
4590
5288
|
|
|
4591
5289
|
return assignments;
|
|
@@ -4941,7 +5639,7 @@ function toolForkConversation(fromMessageId, branchName) {
|
|
|
4941
5639
|
saveAgents(agents);
|
|
4942
5640
|
}
|
|
4943
5641
|
} finally { unlockAgentsFile(); }
|
|
4944
|
-
} catch {}
|
|
5642
|
+
} catch (e) { log.warn("auto role rebalance failed:", e.message); }
|
|
4945
5643
|
|
|
4946
5644
|
return { success: true, branch: branchName, forked_from: branches[branchName].forked_from, messages_copied: forkedHistory.length };
|
|
4947
5645
|
}
|
|
@@ -4965,7 +5663,7 @@ function toolSwitchBranch(branchName) {
|
|
|
4965
5663
|
saveAgents(agents);
|
|
4966
5664
|
}
|
|
4967
5665
|
} finally { unlockAgentsFile(); }
|
|
4968
|
-
} catch {}
|
|
5666
|
+
} catch (e) { log.warn("quality lead failover failed:", e.message); }
|
|
4969
5667
|
|
|
4970
5668
|
return { success: true, branch: branchName, message: `Switched to branch "${branchName}". Read offset reset.` };
|
|
4971
5669
|
}
|
|
@@ -4987,38 +5685,17 @@ function toolListBranches() {
|
|
|
4987
5685
|
|
|
4988
5686
|
// --- Tier 1: Briefing, File Locking, Decisions, Recovery ---
|
|
4989
5687
|
|
|
4990
|
-
//
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
_fileCacheKeys[REPUTATION_FILE] = 'reputation';
|
|
5002
|
-
_fileCacheKeys[RULES_FILE] = 'rules';
|
|
5003
|
-
|
|
5004
|
-
function writeJsonFile(file, data) {
|
|
5005
|
-
ensureDataDir();
|
|
5006
|
-
const str = JSON.stringify(data);
|
|
5007
|
-
if (str && str.length > 0) {
|
|
5008
|
-
// Use file lock to prevent concurrent write corruption
|
|
5009
|
-
const lockPath = file + '.lock';
|
|
5010
|
-
let locked = false;
|
|
5011
|
-
try { fs.writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); locked = true; } catch {}
|
|
5012
|
-
try {
|
|
5013
|
-
fs.writeFileSync(file, str);
|
|
5014
|
-
} finally {
|
|
5015
|
-
if (locked) try { fs.unlinkSync(lockPath); } catch {}
|
|
5016
|
-
}
|
|
5017
|
-
// Auto-invalidate cache for this file
|
|
5018
|
-
const cacheKey = _fileCacheKeys[file];
|
|
5019
|
-
if (cacheKey) invalidateCache(cacheKey);
|
|
5020
|
-
}
|
|
5021
|
-
}
|
|
5688
|
+
// readJsonFile, writeJsonFile, registerFileCacheKey imported from lib/file-io.js
|
|
5689
|
+
// Register file-to-cache-key mappings so writeJsonFile auto-invalidates
|
|
5690
|
+
registerFileCacheKey(DECISIONS_FILE, 'decisions');
|
|
5691
|
+
registerFileCacheKey(KB_FILE, 'kb');
|
|
5692
|
+
registerFileCacheKey(LOCKS_FILE, 'locks');
|
|
5693
|
+
registerFileCacheKey(PROGRESS_FILE, 'progress');
|
|
5694
|
+
registerFileCacheKey(VOTES_FILE, 'votes');
|
|
5695
|
+
registerFileCacheKey(REVIEWS_FILE, 'reviews');
|
|
5696
|
+
registerFileCacheKey(DEPS_FILE, 'deps');
|
|
5697
|
+
registerFileCacheKey(REPUTATION_FILE, 'reputation');
|
|
5698
|
+
registerFileCacheKey(RULES_FILE, 'rules');
|
|
5022
5699
|
|
|
5023
5700
|
function getDecisions() { return cachedRead('decisions', () => readJsonFile(DECISIONS_FILE) || [], 2000); }
|
|
5024
5701
|
function getKB() { return cachedRead('kb', () => readJsonFile(KB_FILE) || {}, 2000); }
|
|
@@ -5029,6 +5706,71 @@ function getReviews() { return cachedRead('reviews', () => readJsonFile(REVIEWS_
|
|
|
5029
5706
|
function getDeps() { return cachedRead('deps', () => readJsonFile(DEPS_FILE) || [], 2000); }
|
|
5030
5707
|
function getRules() { return cachedRead('rules', () => readJsonFile(RULES_FILE) || [], 2000); }
|
|
5031
5708
|
|
|
5709
|
+
// --- Notification system ---
|
|
5710
|
+
const MAX_NOTIFICATIONS = 500;
|
|
5711
|
+
|
|
5712
|
+
function getNotifications() {
|
|
5713
|
+
return readJsonFile(NOTIFICATIONS_FILE) || [];
|
|
5714
|
+
}
|
|
5715
|
+
|
|
5716
|
+
function saveNotifications(notifs) {
|
|
5717
|
+
// Prune to max cap
|
|
5718
|
+
if (notifs.length > MAX_NOTIFICATIONS) {
|
|
5719
|
+
notifs = notifs.slice(notifs.length - MAX_NOTIFICATIONS);
|
|
5720
|
+
}
|
|
5721
|
+
writeJsonFile(NOTIFICATIONS_FILE, notifs);
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
function appendNotification(type, sourceAgent, summary, relatedId) {
|
|
5725
|
+
const notifs = getNotifications();
|
|
5726
|
+
notifs.push({
|
|
5727
|
+
id: 'notif_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
5728
|
+
type: type,
|
|
5729
|
+
source_agent: sourceAgent || registeredName || '__system__',
|
|
5730
|
+
related_id: relatedId || null,
|
|
5731
|
+
summary: summary,
|
|
5732
|
+
timestamp: new Date().toISOString(),
|
|
5733
|
+
read_by: [],
|
|
5734
|
+
});
|
|
5735
|
+
saveNotifications(notifs);
|
|
5736
|
+
}
|
|
5737
|
+
|
|
5738
|
+
function toolGetNotifications(since, type) {
|
|
5739
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
5740
|
+
let notifs = getNotifications();
|
|
5741
|
+
// Filter unread for this agent
|
|
5742
|
+
notifs = notifs.filter(n => !n.read_by.includes(registeredName));
|
|
5743
|
+
if (since) {
|
|
5744
|
+
const sinceTs = new Date(since).getTime();
|
|
5745
|
+
notifs = notifs.filter(n => new Date(n.timestamp).getTime() > sinceTs);
|
|
5746
|
+
}
|
|
5747
|
+
if (type) {
|
|
5748
|
+
notifs = notifs.filter(n => n.type === type);
|
|
5749
|
+
}
|
|
5750
|
+
// Mark as read
|
|
5751
|
+
if (notifs.length > 0) {
|
|
5752
|
+
const allNotifs = getNotifications();
|
|
5753
|
+
const readIds = new Set(notifs.map(n => n.id));
|
|
5754
|
+
for (const n of allNotifs) {
|
|
5755
|
+
if (readIds.has(n.id) && !n.read_by.includes(registeredName)) {
|
|
5756
|
+
n.read_by.push(registeredName);
|
|
5757
|
+
}
|
|
5758
|
+
}
|
|
5759
|
+
saveNotifications(allNotifs);
|
|
5760
|
+
}
|
|
5761
|
+
return {
|
|
5762
|
+
count: notifs.length,
|
|
5763
|
+
notifications: notifs.map(n => ({
|
|
5764
|
+
id: n.id,
|
|
5765
|
+
type: n.type,
|
|
5766
|
+
source_agent: n.source_agent,
|
|
5767
|
+
related_id: n.related_id,
|
|
5768
|
+
summary: n.summary,
|
|
5769
|
+
timestamp: n.timestamp,
|
|
5770
|
+
})),
|
|
5771
|
+
};
|
|
5772
|
+
}
|
|
5773
|
+
|
|
5032
5774
|
// --- Channel helpers ---
|
|
5033
5775
|
const CHANNELS_FILE_PATH = path.join(DATA_DIR, 'channels.json');
|
|
5034
5776
|
|
|
@@ -5166,7 +5908,7 @@ function escalateBlockedTasks() {
|
|
|
5166
5908
|
}
|
|
5167
5909
|
}
|
|
5168
5910
|
if (changed) saveTasks(tasks);
|
|
5169
|
-
} catch {}
|
|
5911
|
+
} catch (e) { log.warn("watchdog check failed:", e.message); }
|
|
5170
5912
|
}
|
|
5171
5913
|
|
|
5172
5914
|
// Stand-up meetings: periodic team check-ins triggered by heartbeat
|
|
@@ -5183,7 +5925,7 @@ function triggerStandupIfDue() {
|
|
|
5183
5925
|
const standupFile = path.join(DATA_DIR, '.last-standup');
|
|
5184
5926
|
let lastStandup = 0;
|
|
5185
5927
|
if (fs.existsSync(standupFile)) {
|
|
5186
|
-
try { lastStandup = parseInt(fs.readFileSync(standupFile, 'utf8').trim()) || 0; } catch {}
|
|
5928
|
+
try { lastStandup = parseInt(fs.readFileSync(standupFile, 'utf8').trim()) || 0; } catch (e) { log.debug('standup file read failed:', e.message); }
|
|
5187
5929
|
}
|
|
5188
5930
|
if (now - lastStandup < intervalMs) return;
|
|
5189
5931
|
|
|
@@ -5207,7 +5949,116 @@ function triggerStandupIfDue() {
|
|
|
5207
5949
|
summary += ' Each agent: report what you did, what\'s blocked, what\'s next. Then call listen_group().';
|
|
5208
5950
|
|
|
5209
5951
|
broadcastSystemMessage(summary, registeredName);
|
|
5210
|
-
} catch {}
|
|
5952
|
+
} catch (e) { log.warn("standup trigger failed:", e.message); }
|
|
5953
|
+
}
|
|
5954
|
+
|
|
5955
|
+
// --- Agent status change detection (heartbeat-driven) ---
|
|
5956
|
+
const _prevAgentAlive = {};
|
|
5957
|
+
function detectAgentStatusChanges(agents) {
|
|
5958
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
5959
|
+
if (name === registeredName) continue;
|
|
5960
|
+
const alive = isPidAlive(info.pid, info.last_activity);
|
|
5961
|
+
const wasAlive = _prevAgentAlive[name];
|
|
5962
|
+
if (wasAlive !== undefined && wasAlive !== alive) {
|
|
5963
|
+
if (!alive) {
|
|
5964
|
+
broadcastSystemMessage(`[STATUS] ${name} is unreachable`, name);
|
|
5965
|
+
appendNotification('agent_offline', name, `${name} went offline`, null);
|
|
5966
|
+
} else {
|
|
5967
|
+
broadcastSystemMessage(`[STATUS] ${name} is back online`, null);
|
|
5968
|
+
appendNotification('agent_online', name, `${name} came back online`, null);
|
|
5969
|
+
}
|
|
5970
|
+
}
|
|
5971
|
+
_prevAgentAlive[name] = alive;
|
|
5972
|
+
}
|
|
5973
|
+
}
|
|
5974
|
+
|
|
5975
|
+
// --- Auto-nudge system: detect agents that haven't called listen() recently ---
|
|
5976
|
+
const AUTO_NUDGE_THRESHOLD_MS = 30000; // 30 seconds
|
|
5977
|
+
const _lastNudgeSent = {}; // Track when we last nudged each agent
|
|
5978
|
+
|
|
5979
|
+
function checkListenCompliance(agents) {
|
|
5980
|
+
const now = Date.now();
|
|
5981
|
+
|
|
5982
|
+
for (const [name, info] of Object.entries(agents)) {
|
|
5983
|
+
if (name === registeredName) continue; // Skip self
|
|
5984
|
+
if (!isPidAlive(info.pid, info.last_activity)) continue; // Skip dead agents
|
|
5985
|
+
|
|
5986
|
+
// Skip agents currently in a listen() call — they're compliant
|
|
5987
|
+
if (info.is_listening) continue;
|
|
5988
|
+
|
|
5989
|
+
// Skip Coordinator (lead role) in responsive mode — they use check_messages, not listen()
|
|
5990
|
+
try {
|
|
5991
|
+
const profiles = getProfiles();
|
|
5992
|
+
if (profiles[name] && profiles[name].role === 'lead') {
|
|
5993
|
+
const coordMode = (getConfig().coordinator_mode || 'responsive');
|
|
5994
|
+
if (coordMode === 'responsive') continue;
|
|
5995
|
+
}
|
|
5996
|
+
} catch (_) { /* fall through */ }
|
|
5997
|
+
|
|
5998
|
+
// Skip agents that registered recently (within 60s) — give them time to call listen()
|
|
5999
|
+
const registeredAt = info.registered_at ? new Date(info.registered_at).getTime() : 0;
|
|
6000
|
+
if (registeredAt && (now - registeredAt) < 60000) continue;
|
|
6001
|
+
|
|
6002
|
+
// Check if agent has recent activity but no recent listen call
|
|
6003
|
+
const lastActivity = info.last_activity ? new Date(info.last_activity).getTime() : 0;
|
|
6004
|
+
const timeSinceActivity = now - lastActivity;
|
|
6005
|
+
|
|
6006
|
+
// Only check agents that have been active recently (within 5 minutes)
|
|
6007
|
+
if (timeSinceActivity > 300000) continue; // Skip inactive agents
|
|
6008
|
+
|
|
6009
|
+
// Determine agent's start time and role for filtering
|
|
6010
|
+
const startedAt = info.started_at ? new Date(info.started_at).getTime() : lastActivity || now;
|
|
6011
|
+
const profiles = getProfiles();
|
|
6012
|
+
const role = (profiles[name] || {}).role;
|
|
6013
|
+
|
|
6014
|
+
// GUARD: Skip Coordinator role (they orchestrate, let them skip listen cycles)
|
|
6015
|
+
if (role === 'Coordinator') continue;
|
|
6016
|
+
|
|
6017
|
+
// GUARD: Skip agents registered within the last 60 seconds (grace period)
|
|
6018
|
+
if (now - startedAt < 60000) continue;
|
|
6019
|
+
|
|
6020
|
+
// GUARD: Skip agents currently in a listen loop
|
|
6021
|
+
if (info.listening_since) continue;
|
|
6022
|
+
|
|
6023
|
+
// Check for recent listen call in heartbeat file
|
|
6024
|
+
// Fallback to registry last_listened_at before defaulting to startedAt
|
|
6025
|
+
let lastListenCall = info.last_listened_at ? new Date(info.last_listened_at).getTime() : startedAt;
|
|
6026
|
+
|
|
6027
|
+
try {
|
|
6028
|
+
const heartbeatPath = heartbeatFile(name);
|
|
6029
|
+
if (fs.existsSync(heartbeatPath)) {
|
|
6030
|
+
const heartbeat = JSON.parse(fs.readFileSync(heartbeatPath, 'utf8'));
|
|
6031
|
+
if (heartbeat.last_listen_call) {
|
|
6032
|
+
lastListenCall = new Date(heartbeat.last_listen_call).getTime();
|
|
6033
|
+
} else if (heartbeat.listen_history && heartbeat.listen_history.length > 0) {
|
|
6034
|
+
// Fallback to latest history entry
|
|
6035
|
+
lastListenCall = heartbeat.listen_history[0];
|
|
6036
|
+
}
|
|
6037
|
+
}
|
|
6038
|
+
} catch (e) {
|
|
6039
|
+
// Ignore heartbeat read errors
|
|
6040
|
+
continue;
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
// Calculate time since last listen call
|
|
6044
|
+
const timeSinceListenCall = now - lastListenCall;
|
|
6045
|
+
|
|
6046
|
+
// If agent has been active but hasn't called listen() in 30+ seconds, nudge them
|
|
6047
|
+
if (timeSinceListenCall > AUTO_NUDGE_THRESHOLD_MS) {
|
|
6048
|
+
// Avoid spamming - only nudge once every 2 minutes per agent
|
|
6049
|
+
const lastNudge = _lastNudgeSent[name] || 0;
|
|
6050
|
+
if (now - lastNudge > 120000) { // 2 minutes
|
|
6051
|
+
_lastNudgeSent[name] = now;
|
|
6052
|
+
|
|
6053
|
+
// Log only — don't inject messages. Agents that lost listen() can't
|
|
6054
|
+
// receive messages anyway; the in-server tool response warnings and
|
|
6055
|
+
// 5-call blocking handle active agents. Injecting CRITICAL messages
|
|
6056
|
+
// just spams the dashboard with no effect.
|
|
6057
|
+
const minutesSinceListenCall = Math.round(timeSinceListenCall / 60000);
|
|
6058
|
+
log.info(`[auto-nudge] ${name} hasn't called listen() in ${minutesSinceListenCall}m`);
|
|
6059
|
+
}
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
5211
6062
|
}
|
|
5212
6063
|
|
|
5213
6064
|
// Auto-recovery: snapshot dead agent state before cleanup
|
|
@@ -5249,7 +6100,7 @@ function snapshotDeadAgents(agents) {
|
|
|
5249
6100
|
kb_entries_written: kbKeysWritten,
|
|
5250
6101
|
});
|
|
5251
6102
|
}
|
|
5252
|
-
} catch {}
|
|
6103
|
+
} catch (e) { log.warn("dead agent snapshot failed:", e.message); }
|
|
5253
6104
|
|
|
5254
6105
|
// Quality Lead instant failover: if dead agent was Quality Lead, promote replacement immediately
|
|
5255
6106
|
try {
|
|
@@ -5301,7 +6152,7 @@ function snapshotDeadAgents(agents) {
|
|
|
5301
6152
|
broadcastSystemMessage(`[MONITOR FAILOVER] ${name} (Monitor) went offline. ${newMonitor} has been auto-promoted.`, newMonitor);
|
|
5302
6153
|
}
|
|
5303
6154
|
}
|
|
5304
|
-
} catch {}
|
|
6155
|
+
} catch (e) { log.warn("monitor failover failed:", e.message); }
|
|
5305
6156
|
}
|
|
5306
6157
|
}
|
|
5307
6158
|
|
|
@@ -5352,9 +6203,37 @@ function fireEvent(eventName, data) {
|
|
|
5352
6203
|
}
|
|
5353
6204
|
break;
|
|
5354
6205
|
}
|
|
6206
|
+
case 'review_approved': {
|
|
6207
|
+
if (data.author && agents[data.author] && isPidAlive(agents[data.author].pid, agents[data.author].last_activity)) {
|
|
6208
|
+
sendSystemMessage(data.author, `[EVENT] "${data.file}" approved by ${data.reviewer}. You should commit your changes now.`);
|
|
6209
|
+
}
|
|
6210
|
+
break;
|
|
6211
|
+
}
|
|
5355
6212
|
}
|
|
6213
|
+
|
|
6214
|
+
// Hook system: emit to all subscribers of mapped events
|
|
6215
|
+
try {
|
|
6216
|
+
const hooksLib = require('./lib/hooks');
|
|
6217
|
+
const hookEvent = EVENT_TO_HOOK[eventName];
|
|
6218
|
+
if (hookEvent) {
|
|
6219
|
+
const hookData = { ...data, _source_agent: registeredName };
|
|
6220
|
+
const notifications = hooksLib.emit(hookEvent, hookData);
|
|
6221
|
+
for (const n of notifications) {
|
|
6222
|
+
if (agents[n.agent] && isPidAlive(agents[n.agent].pid, agents[n.agent].last_activity)) {
|
|
6223
|
+
sendSystemMessage(n.agent, n.message);
|
|
6224
|
+
}
|
|
6225
|
+
}
|
|
6226
|
+
}
|
|
6227
|
+
} catch (e) { log.debug('hook emit failed:', e.message); }
|
|
5356
6228
|
}
|
|
5357
6229
|
|
|
6230
|
+
// Map internal event names to hook event names
|
|
6231
|
+
const EVENT_TO_HOOK = {
|
|
6232
|
+
task_complete: 'task.status_changed',
|
|
6233
|
+
review_approved: 'review.submitted',
|
|
6234
|
+
rule_changed: 'rule.changed',
|
|
6235
|
+
};
|
|
6236
|
+
|
|
5358
6237
|
function toolGetGuide(level = 'standard') {
|
|
5359
6238
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
5360
6239
|
if (!['minimal', 'standard', 'full'].includes(level)) return { error: 'Level must be "minimal", "standard", or "full"' };
|
|
@@ -5723,11 +6602,12 @@ function toolSubmitReview(reviewId, status, feedback) {
|
|
|
5723
6602
|
rep[review.requested_by].demoted = false;
|
|
5724
6603
|
writeJsonFile(REPUTATION_FILE, rep);
|
|
5725
6604
|
}
|
|
5726
|
-
// Notify requester
|
|
6605
|
+
// Notify requester and fire review_approved event
|
|
5727
6606
|
const agents = getAgents();
|
|
5728
6607
|
if (agents[review.requested_by]) {
|
|
5729
6608
|
sendSystemMessage(review.requested_by, `[REVIEW] ${registeredName} approved "${review.file}": ${review.feedback || 'Looks good!'}`);
|
|
5730
6609
|
}
|
|
6610
|
+
fireEvent('review_approved', { file: review.file, reviewer: registeredName, author: review.requested_by });
|
|
5731
6611
|
}
|
|
5732
6612
|
|
|
5733
6613
|
// Auto-approve check: if this is a re-submission and auto_approve_next is set
|
|
@@ -6040,11 +6920,12 @@ function toolSuggestTask() {
|
|
|
6040
6920
|
|
|
6041
6921
|
// --- Rules system: project-level rules visible in dashboard and injected into agent guides ---
|
|
6042
6922
|
|
|
6043
|
-
function toolAddRule(text, category = 'custom') {
|
|
6923
|
+
function toolAddRule(text, category = 'custom', scope = null) {
|
|
6044
6924
|
if (!registeredName) return { error: 'You must call register() first' };
|
|
6045
6925
|
if (!text || !text.trim()) return { error: 'Rule text cannot be empty' };
|
|
6046
6926
|
const validCategories = ['safety', 'workflow', 'code-style', 'communication', 'custom'];
|
|
6047
6927
|
if (!validCategories.includes(category)) return { error: `Category must be one of: ${validCategories.join(', ')}` };
|
|
6928
|
+
if (scope && typeof scope !== 'object') return { error: 'scope must be an object with optional fields: role, provider, agent' };
|
|
6048
6929
|
|
|
6049
6930
|
const rules = getRules();
|
|
6050
6931
|
const rule = {
|
|
@@ -6055,9 +6936,25 @@ function toolAddRule(text, category = 'custom') {
|
|
|
6055
6936
|
created_at: new Date().toISOString(),
|
|
6056
6937
|
active: true,
|
|
6057
6938
|
};
|
|
6939
|
+
if (scope) {
|
|
6940
|
+
if (scope.role) rule.scope_role = String(scope.role).toLowerCase();
|
|
6941
|
+
if (scope.provider) rule.scope_provider = String(scope.provider).toLowerCase();
|
|
6942
|
+
if (scope.agent) rule.scope_agent = String(scope.agent);
|
|
6943
|
+
}
|
|
6058
6944
|
rules.push(rule);
|
|
6059
6945
|
writeJsonFile(RULES_FILE, rules);
|
|
6060
|
-
|
|
6946
|
+
const scopeMsg = scope ? ` (scoped to ${JSON.stringify(scope)})` : '';
|
|
6947
|
+
fireEvent('rule_changed', {
|
|
6948
|
+
action: 'added',
|
|
6949
|
+
rule_id: rule.id,
|
|
6950
|
+
text: rule.text,
|
|
6951
|
+
category: rule.category,
|
|
6952
|
+
scope_role: rule.scope_role || null,
|
|
6953
|
+
scope_provider: rule.scope_provider || null,
|
|
6954
|
+
scope_agent: rule.scope_agent || null,
|
|
6955
|
+
changed_by: registeredName,
|
|
6956
|
+
});
|
|
6957
|
+
return { success: true, rule_id: rule.id, message: `Rule added: "${text.substring(0, 80)}"${scopeMsg}. Matching agents will see this in their guide.` };
|
|
6061
6958
|
}
|
|
6062
6959
|
|
|
6063
6960
|
function toolListRules() {
|
|
@@ -6080,6 +6977,16 @@ function toolRemoveRule(ruleId) {
|
|
|
6080
6977
|
if (idx === -1) return { error: `Rule not found: ${ruleId}` };
|
|
6081
6978
|
const removed = rules.splice(idx, 1)[0];
|
|
6082
6979
|
writeJsonFile(RULES_FILE, rules);
|
|
6980
|
+
fireEvent('rule_changed', {
|
|
6981
|
+
action: 'removed',
|
|
6982
|
+
rule_id: removed.id,
|
|
6983
|
+
text: removed.text,
|
|
6984
|
+
category: removed.category,
|
|
6985
|
+
scope_role: removed.scope_role || null,
|
|
6986
|
+
scope_provider: removed.scope_provider || null,
|
|
6987
|
+
scope_agent: removed.scope_agent || null,
|
|
6988
|
+
changed_by: registeredName,
|
|
6989
|
+
});
|
|
6083
6990
|
return { success: true, removed: removed.text.substring(0, 80), message: 'Rule removed.' };
|
|
6084
6991
|
}
|
|
6085
6992
|
|
|
@@ -6091,14 +6998,252 @@ function toolToggleRule(ruleId) {
|
|
|
6091
6998
|
if (!rule) return { error: `Rule not found: ${ruleId}` };
|
|
6092
6999
|
rule.active = !rule.active;
|
|
6093
7000
|
writeJsonFile(RULES_FILE, rules);
|
|
7001
|
+
fireEvent('rule_changed', {
|
|
7002
|
+
action: rule.active ? 'activated' : 'deactivated',
|
|
7003
|
+
rule_id: rule.id,
|
|
7004
|
+
text: rule.text,
|
|
7005
|
+
category: rule.category,
|
|
7006
|
+
scope_role: rule.scope_role || null,
|
|
7007
|
+
scope_provider: rule.scope_provider || null,
|
|
7008
|
+
scope_agent: rule.scope_agent || null,
|
|
7009
|
+
changed_by: registeredName,
|
|
7010
|
+
});
|
|
6094
7011
|
return { success: true, rule_id: ruleId, active: rule.active, message: `Rule ${rule.active ? 'activated' : 'deactivated'}.` };
|
|
6095
7012
|
}
|
|
6096
7013
|
|
|
7014
|
+
// --- Audit log ---
|
|
7015
|
+
|
|
7016
|
+
function logViolation(type, agent, details) {
|
|
7017
|
+
const entry = {
|
|
7018
|
+
timestamp: new Date().toISOString(),
|
|
7019
|
+
type,
|
|
7020
|
+
agent,
|
|
7021
|
+
details: (details || '').substring(0, 1000),
|
|
7022
|
+
};
|
|
7023
|
+
try {
|
|
7024
|
+
fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + '\n');
|
|
7025
|
+
} catch (e) { log.debug('audit log write failed:', e.message); }
|
|
7026
|
+
return entry;
|
|
7027
|
+
}
|
|
7028
|
+
|
|
7029
|
+
function toolLogViolation(type, details) {
|
|
7030
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
7031
|
+
if (!type) return { error: 'type is required (e.g., "review_skipped", "push_without_approval", "rule_violated")' };
|
|
7032
|
+
const entry = logViolation(type, registeredName, details);
|
|
7033
|
+
return { success: true, logged: entry, message: `Violation logged: ${type}` };
|
|
7034
|
+
}
|
|
7035
|
+
|
|
7036
|
+
// --- Push approval system ---
|
|
7037
|
+
|
|
7038
|
+
const PUSH_AUTO_APPROVE_MS = 120000; // 2 minutes
|
|
7039
|
+
|
|
7040
|
+
function getPushRequests() { return cachedRead('push_requests', () => readJsonFile(PUSH_REQUESTS_FILE) || [], 2000); }
|
|
7041
|
+
|
|
7042
|
+
function toolRequestPushApproval(branch, description) {
|
|
7043
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
7044
|
+
if (!branch) return { error: 'branch is required' };
|
|
7045
|
+
|
|
7046
|
+
const agents = getAgents();
|
|
7047
|
+
const aliveOthers = Object.keys(agents).filter(n => n !== registeredName && isPidAlive(agents[n].pid, agents[n].last_activity));
|
|
7048
|
+
|
|
7049
|
+
// Auto-approve if no other agents online
|
|
7050
|
+
if (aliveOthers.length === 0) {
|
|
7051
|
+
return { approved: true, auto: true, message: 'No other agents online — auto-approved. You may push.' };
|
|
7052
|
+
}
|
|
7053
|
+
|
|
7054
|
+
const requests = getPushRequests();
|
|
7055
|
+
const id = 'push_' + generateId();
|
|
7056
|
+
const request = {
|
|
7057
|
+
id,
|
|
7058
|
+
branch: branch.substring(0, 100),
|
|
7059
|
+
description: (description || '').substring(0, 500),
|
|
7060
|
+
requested_by: registeredName,
|
|
7061
|
+
requested_at: new Date().toISOString(),
|
|
7062
|
+
status: 'pending',
|
|
7063
|
+
acked_by: null,
|
|
7064
|
+
};
|
|
7065
|
+
requests.push(request);
|
|
7066
|
+
writeJsonFile(PUSH_REQUESTS_FILE, requests);
|
|
7067
|
+
|
|
7068
|
+
broadcastSystemMessage(`[PUSH REQUEST] ${registeredName} wants to push branch "${branch}". ${description || ''}. Call ack_push("${id}") to approve.`, registeredName);
|
|
7069
|
+
|
|
7070
|
+
return {
|
|
7071
|
+
request_id: id,
|
|
7072
|
+
status: 'pending',
|
|
7073
|
+
waiting_on: aliveOthers,
|
|
7074
|
+
auto_approve_after: '2 minutes',
|
|
7075
|
+
message: `Push request created. Waiting for approval from ${aliveOthers.join(', ')}. Auto-approves in 2 minutes if no response.`,
|
|
7076
|
+
};
|
|
7077
|
+
}
|
|
7078
|
+
|
|
7079
|
+
function toolAckPush(requestId) {
|
|
7080
|
+
if (!registeredName) return { error: 'You must call register() first' };
|
|
7081
|
+
if (!requestId) return { error: 'request_id is required' };
|
|
7082
|
+
|
|
7083
|
+
const requests = getPushRequests();
|
|
7084
|
+
const req = requests.find(r => r.id === requestId);
|
|
7085
|
+
if (!req) return { error: `Push request not found: ${requestId}` };
|
|
7086
|
+
if (req.requested_by === registeredName) return { error: 'Cannot approve your own push request.' };
|
|
7087
|
+
if (req.status !== 'pending') return { error: `Push request already ${req.status}.` };
|
|
7088
|
+
|
|
7089
|
+
req.status = 'approved';
|
|
7090
|
+
req.acked_by = registeredName;
|
|
7091
|
+
req.acked_at = new Date().toISOString();
|
|
7092
|
+
writeJsonFile(PUSH_REQUESTS_FILE, requests);
|
|
7093
|
+
|
|
7094
|
+
sendSystemMessage(req.requested_by, `[PUSH APPROVED] ${registeredName} approved your push of "${req.branch}". You may push now.`);
|
|
7095
|
+
|
|
7096
|
+
return { success: true, request_id: requestId, message: `Push approved for ${req.requested_by} on branch "${req.branch}".` };
|
|
7097
|
+
}
|
|
7098
|
+
|
|
7099
|
+
function checkPushAutoApprove(requestId) {
|
|
7100
|
+
const requests = getPushRequests();
|
|
7101
|
+
const req = requests.find(r => r.id === requestId);
|
|
7102
|
+
if (!req || req.status !== 'pending') return;
|
|
7103
|
+
|
|
7104
|
+
const elapsed = Date.now() - new Date(req.requested_at).getTime();
|
|
7105
|
+
if (elapsed >= PUSH_AUTO_APPROVE_MS) {
|
|
7106
|
+
req.status = 'auto_approved';
|
|
7107
|
+
req.acked_by = '__system__';
|
|
7108
|
+
req.acked_at = new Date().toISOString();
|
|
7109
|
+
writeJsonFile(PUSH_REQUESTS_FILE, requests);
|
|
7110
|
+
sendSystemMessage(req.requested_by, `[PUSH AUTO-APPROVED] No response after 2 minutes. Push of "${req.branch}" auto-approved. You may push now.`);
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
7113
|
+
|
|
7114
|
+
// --- Modular tools (tools/) ---
|
|
7115
|
+
// Each module exports { definitions, handlers } via a context-injection pattern.
|
|
7116
|
+
// Context provides shared state, helper functions, and file paths.
|
|
7117
|
+
|
|
7118
|
+
const _governanceCtx = {
|
|
7119
|
+
state: { get registeredName() { return registeredName; } },
|
|
7120
|
+
helpers: {
|
|
7121
|
+
getVotes, getReviews, getRules, getPushRequests,
|
|
7122
|
+
getAgents, isPidAlive, getReputation, getTasks, saveTasks,
|
|
7123
|
+
generateId, readJsonFile, writeJsonFile, cachedRead, invalidateCache,
|
|
7124
|
+
broadcastSystemMessage, sendSystemMessage, touchActivity, fireEvent,
|
|
7125
|
+
},
|
|
7126
|
+
files: {
|
|
7127
|
+
VOTES_FILE, REVIEWS_FILE, RULES_FILE,
|
|
7128
|
+
PUSH_REQUESTS_FILE, AUDIT_LOG_FILE, REPUTATION_FILE,
|
|
7129
|
+
},
|
|
7130
|
+
};
|
|
7131
|
+
const governance = require('./tools/governance')(_governanceCtx);
|
|
7132
|
+
|
|
7133
|
+
const _tasksCtx = {
|
|
7134
|
+
state: {
|
|
7135
|
+
get registeredName() { return registeredName; },
|
|
7136
|
+
get messageSeq() { return messageSeq; },
|
|
7137
|
+
set messageSeq(v) { messageSeq = v; },
|
|
7138
|
+
get currentBranch() { return currentBranch; },
|
|
7139
|
+
},
|
|
7140
|
+
helpers: {
|
|
7141
|
+
getTasks, saveTasks, getAgents, isPidAlive, generateId, writeJsonFile,
|
|
7142
|
+
broadcastSystemMessage, sendSystemMessage, touchActivity, fireEvent,
|
|
7143
|
+
ensureDataDir, getProfiles, getReviews, getReputation, getDeps,
|
|
7144
|
+
getChannelsData, saveChannelsData, isGroupMode,
|
|
7145
|
+
getWorkspace, saveWorkspace, appendNotification,
|
|
7146
|
+
getWorkflows, saveWorkflows, saveWorkflowCheckpoint, findReadySteps,
|
|
7147
|
+
getMessagesFile, getHistoryFile, logViolation, cachedRead,
|
|
7148
|
+
},
|
|
7149
|
+
files: { TASKS_FILE, REVIEWS_FILE, DEPS_FILE },
|
|
7150
|
+
};
|
|
7151
|
+
const tasks = require('./tools/tasks')(_tasksCtx);
|
|
7152
|
+
|
|
7153
|
+
const _workflowsCtx = {
|
|
7154
|
+
state: {
|
|
7155
|
+
get registeredName() { return registeredName; },
|
|
7156
|
+
get messageSeq() { return messageSeq; },
|
|
7157
|
+
set messageSeq(v) { messageSeq = v; },
|
|
7158
|
+
get currentBranch() { return currentBranch; },
|
|
7159
|
+
},
|
|
7160
|
+
helpers: {
|
|
7161
|
+
getWorkflows, saveWorkflows, saveWorkflowCheckpoint, findReadySteps,
|
|
7162
|
+
getAgents, isPidAlive, getTasks, saveTasks, generateId, ensureDataDir,
|
|
7163
|
+
broadcastSystemMessage, sendSystemMessage, touchActivity, appendNotification,
|
|
7164
|
+
getMessagesFile, getHistoryFile, canSendTo, generateCompletionReport,
|
|
7165
|
+
},
|
|
7166
|
+
files: {},
|
|
7167
|
+
};
|
|
7168
|
+
const workflows = require('./tools/workflows')(_workflowsCtx);
|
|
7169
|
+
|
|
7170
|
+
const _knowledgeCtx = {
|
|
7171
|
+
state: {
|
|
7172
|
+
get registeredName() { return registeredName; },
|
|
7173
|
+
get currentBranch() { return currentBranch; },
|
|
7174
|
+
},
|
|
7175
|
+
helpers: {
|
|
7176
|
+
getDecisions, getKB, getProgressData, getCompressed, getLocks, getConfig,
|
|
7177
|
+
generateId, writeJsonFile, readJsonFile, touchActivity, tailReadJsonl,
|
|
7178
|
+
getHistoryFile, getAgents, isPidAlive, getProfiles, getTasks, cachedRead,
|
|
7179
|
+
},
|
|
7180
|
+
files: { DECISIONS_FILE, KB_FILE, PROGRESS_FILE, COMPRESSED_FILE },
|
|
7181
|
+
};
|
|
7182
|
+
const knowledge = require('./tools/knowledge')(_knowledgeCtx);
|
|
7183
|
+
|
|
7184
|
+
const _channelsCtx = {
|
|
7185
|
+
state: { get registeredName() { return registeredName; } },
|
|
7186
|
+
helpers: {
|
|
7187
|
+
getChannelsData, saveChannelsData, sanitizeName,
|
|
7188
|
+
isChannelMember, getAgentChannels, getChannelMessagesFile,
|
|
7189
|
+
touchActivity,
|
|
7190
|
+
},
|
|
7191
|
+
files: {},
|
|
7192
|
+
};
|
|
7193
|
+
const channels = require('./tools/channels')(_channelsCtx);
|
|
7194
|
+
|
|
7195
|
+
const _safetyCtx = {
|
|
7196
|
+
state: { get registeredName() { return registeredName; } },
|
|
7197
|
+
helpers: {
|
|
7198
|
+
getLocks, getAgents, isPidAlive, getTasks, getDeps,
|
|
7199
|
+
generateId, writeJsonFile, touchActivity,
|
|
7200
|
+
},
|
|
7201
|
+
files: { LOCKS_FILE, DEPS_FILE },
|
|
7202
|
+
};
|
|
7203
|
+
const safety = require('./tools/safety')(_safetyCtx);
|
|
7204
|
+
|
|
7205
|
+
const _systemCtx = {
|
|
7206
|
+
state: {
|
|
7207
|
+
get registeredName() { return registeredName; },
|
|
7208
|
+
get currentBranch() { return currentBranch; },
|
|
7209
|
+
},
|
|
7210
|
+
helpers: {
|
|
7211
|
+
getProfiles, saveProfiles, getWorkspace, saveWorkspace, ensureDataDir,
|
|
7212
|
+
getAgents, getBranches, getHistoryFile, getReputation, touchActivity,
|
|
7213
|
+
},
|
|
7214
|
+
files: {},
|
|
7215
|
+
};
|
|
7216
|
+
const system = require('./tools/system')(_systemCtx);
|
|
7217
|
+
|
|
7218
|
+
const _hooksCtx = {
|
|
7219
|
+
state: { get registeredName() { return registeredName; } },
|
|
7220
|
+
};
|
|
7221
|
+
const hooks = require('./tools/hooks')(_hooksCtx);
|
|
7222
|
+
|
|
7223
|
+
const _messagingCtx = {
|
|
7224
|
+
state: {
|
|
7225
|
+
get registeredName() { return registeredName; },
|
|
7226
|
+
get currentBranch() { return currentBranch; },
|
|
7227
|
+
get lastReadOffset() { return lastReadOffset; },
|
|
7228
|
+
set lastReadOffset(v) { lastReadOffset = v; },
|
|
7229
|
+
},
|
|
7230
|
+
helpers: {
|
|
7231
|
+
getUnconsumedMessages, getConsumedIds, saveConsumedIds, markAsRead,
|
|
7232
|
+
getNotifications, saveNotifications, getAcks, getPermissions,
|
|
7233
|
+
getAgents, isPidAlive, getConfig, touchActivity,
|
|
7234
|
+
tailReadJsonl, readJsonl, getMessagesFile, getHistoryFile,
|
|
7235
|
+
getAgentChannels, getChannelHistoryFile,
|
|
7236
|
+
withFileLock,
|
|
7237
|
+
},
|
|
7238
|
+
files: { ACKS_FILE },
|
|
7239
|
+
};
|
|
7240
|
+
const messaging = require('./tools/messaging')(_messagingCtx);
|
|
7241
|
+
|
|
6097
7242
|
// --- MCP Server setup ---
|
|
6098
7243
|
|
|
6099
7244
|
const server = new Server(
|
|
6100
|
-
{ name: 'neohive', version: '6.
|
|
6101
|
-
{ capabilities: { tools: {} } }
|
|
7245
|
+
{ name: 'neohive', version: '6.1.0' },
|
|
7246
|
+
{ capabilities: { tools: { listChanged: true } } }
|
|
6102
7247
|
);
|
|
6103
7248
|
|
|
6104
7249
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -6118,8 +7263,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6118
7263
|
type: 'string',
|
|
6119
7264
|
description: 'AI provider/CLI name (e.g. "Claude", "OpenAI", "Gemini"). Shown in dashboard.',
|
|
6120
7265
|
},
|
|
7266
|
+
skills: {
|
|
7267
|
+
type: 'array',
|
|
7268
|
+
items: { type: 'string' },
|
|
7269
|
+
description: 'Skills like "python", "testing", "frontend", "design". Used for smart task routing.',
|
|
7270
|
+
},
|
|
6121
7271
|
},
|
|
6122
7272
|
required: ['name'],
|
|
7273
|
+
additionalProperties: false,
|
|
6123
7274
|
},
|
|
6124
7275
|
},
|
|
6125
7276
|
{
|
|
@@ -6128,6 +7279,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6128
7279
|
inputSchema: {
|
|
6129
7280
|
type: 'object',
|
|
6130
7281
|
properties: {},
|
|
7282
|
+
additionalProperties: false,
|
|
6131
7283
|
},
|
|
6132
7284
|
},
|
|
6133
7285
|
{
|
|
@@ -6152,8 +7304,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6152
7304
|
type: 'string',
|
|
6153
7305
|
description: 'Channel to send to (optional — omit for #general). Use join_channel() first to create channels.',
|
|
6154
7306
|
},
|
|
7307
|
+
priority: {
|
|
7308
|
+
type: 'string',
|
|
7309
|
+
enum: ['critical', 'normal', 'low'],
|
|
7310
|
+
description: 'Message priority (optional — auto-classified if omitted). Critical messages are delivered first and retained longer.',
|
|
7311
|
+
},
|
|
6155
7312
|
},
|
|
6156
7313
|
required: ['content'],
|
|
7314
|
+
additionalProperties: false,
|
|
6157
7315
|
},
|
|
6158
7316
|
},
|
|
6159
7317
|
{
|
|
@@ -6171,6 +7329,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6171
7329
|
description: 'Only return messages from this specific agent (optional)',
|
|
6172
7330
|
},
|
|
6173
7331
|
},
|
|
7332
|
+
additionalProperties: false,
|
|
6174
7333
|
},
|
|
6175
7334
|
},
|
|
6176
7335
|
{
|
|
@@ -6185,76 +7344,63 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6185
7344
|
},
|
|
6186
7345
|
},
|
|
6187
7346
|
required: ['content'],
|
|
7347
|
+
additionalProperties: false,
|
|
6188
7348
|
},
|
|
6189
7349
|
},
|
|
6190
7350
|
{
|
|
6191
7351
|
name: 'listen',
|
|
6192
|
-
description: 'Listen for messages
|
|
7352
|
+
description: 'Listen for messages. Use mode="standard" (default, direct 1:1), mode="group" (group/managed conversation, batched), or mode="codex" (Codex CLI — returns after 90s). Auto-detects mode from conversation state when mode is omitted. Replaces listen_group and listen_codex (now deprecated aliases).',
|
|
6193
7353
|
inputSchema: {
|
|
6194
7354
|
type: 'object',
|
|
6195
7355
|
properties: {
|
|
6196
|
-
|
|
7356
|
+
mode: {
|
|
6197
7357
|
type: 'string',
|
|
6198
|
-
|
|
7358
|
+
enum: ['standard', 'group', 'codex'],
|
|
7359
|
+
description: 'Listen mode: "standard" (default, direct), "group" (group/managed batched), "codex" (Codex CLI 90s cap). Auto-detected when omitted.',
|
|
6199
7360
|
},
|
|
6200
|
-
},
|
|
6201
|
-
},
|
|
6202
|
-
},
|
|
6203
|
-
{
|
|
6204
|
-
name: 'listen_codex',
|
|
6205
|
-
description: 'ONLY for Codex CLI agents — do NOT use if you are Claude Code or Gemini CLI. Same as listen() but returns after 90 seconds due to Codex tool timeout limits. Claude and Gemini agents must use listen() instead.',
|
|
6206
|
-
inputSchema: {
|
|
6207
|
-
type: 'object',
|
|
6208
|
-
properties: {
|
|
6209
7361
|
from: {
|
|
6210
7362
|
type: 'string',
|
|
6211
7363
|
description: 'Only listen for messages from this specific agent (optional)',
|
|
6212
7364
|
},
|
|
6213
|
-
|
|
6214
|
-
},
|
|
6215
|
-
},
|
|
6216
|
-
{
|
|
6217
|
-
name: 'check_messages',
|
|
6218
|
-
description: 'Non-blocking PEEK at your inbox — shows message previews but does NOT consume them. Use listen() to actually receive and process messages. Do NOT call this in a loop — it wastes tokens returning the same messages repeatedly. Use listen() instead which blocks efficiently and consumes messages.',
|
|
6219
|
-
inputSchema: {
|
|
6220
|
-
type: 'object',
|
|
6221
|
-
properties: {
|
|
6222
|
-
from: {
|
|
7365
|
+
outcome: {
|
|
6223
7366
|
type: 'string',
|
|
6224
|
-
|
|
7367
|
+
enum: ['completed', 'blocked', 'failed', 'in_progress'],
|
|
7368
|
+
description: 'Optional: report the outcome of your last task before listening. "completed" marks task done, "blocked" marks it blocked, "failed" marks it permanently blocked.',
|
|
6225
7369
|
},
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
description: 'Acknowledge that you have processed a message. Lets the sender verify delivery via get_history.',
|
|
6232
|
-
inputSchema: {
|
|
6233
|
-
type: 'object',
|
|
6234
|
-
properties: {
|
|
6235
|
-
message_id: {
|
|
7370
|
+
task_id: {
|
|
7371
|
+
type: 'string',
|
|
7372
|
+
description: 'Task ID to update with the outcome (required when outcome is set and outcome is not "in_progress")',
|
|
7373
|
+
},
|
|
7374
|
+
summary: {
|
|
6236
7375
|
type: 'string',
|
|
6237
|
-
description: '
|
|
7376
|
+
description: 'Optional: brief summary of what was done or why it was blocked (used as task notes)',
|
|
6238
7377
|
},
|
|
6239
7378
|
},
|
|
6240
|
-
|
|
7379
|
+
additionalProperties: false,
|
|
6241
7380
|
},
|
|
6242
7381
|
},
|
|
7382
|
+
// --- Unified messages tool (consolidates check/consume/history/search/ack) ---
|
|
6243
7383
|
{
|
|
6244
|
-
name: '
|
|
6245
|
-
description: '
|
|
7384
|
+
name: 'messages',
|
|
7385
|
+
description: 'Unified message management. action="check" peeks at unconsumed messages, "consume" marks them read, "history" returns conversation history, "search" searches by keyword, "ack" acknowledges a message, "notifications" returns task/workflow/agent notifications.',
|
|
6246
7386
|
inputSchema: {
|
|
6247
7387
|
type: 'object',
|
|
6248
7388
|
properties: {
|
|
6249
|
-
|
|
6250
|
-
type: 'number',
|
|
6251
|
-
description: 'Number of recent messages to return (default: 50)',
|
|
6252
|
-
},
|
|
6253
|
-
thread_id: {
|
|
7389
|
+
action: {
|
|
6254
7390
|
type: 'string',
|
|
6255
|
-
|
|
7391
|
+
enum: ['check', 'consume', 'history', 'search', 'ack', 'notifications'],
|
|
7392
|
+
description: 'Message action: check (peek), consume (mark read), history, search, ack, notifications',
|
|
6256
7393
|
},
|
|
7394
|
+
from: { type: 'string', description: 'Filter by sender agent name (optional)' },
|
|
7395
|
+
limit: { type: 'number', description: 'Max results (default varies by action)' },
|
|
7396
|
+
query: { type: 'string', description: 'Search term — required for action="search"' },
|
|
7397
|
+
message_id: { type: 'string', description: 'Message ID — required for action="ack"' },
|
|
7398
|
+
thread_id: { type: 'string', description: 'Filter by thread ID (optional, action="history")' },
|
|
7399
|
+
since: { type: 'string', description: 'ISO timestamp filter (optional)' },
|
|
7400
|
+
type: { type: 'string', description: 'Notification type filter (optional, action="notifications")' },
|
|
6257
7401
|
},
|
|
7402
|
+
required: ['action'],
|
|
7403
|
+
additionalProperties: false,
|
|
6258
7404
|
},
|
|
6259
7405
|
},
|
|
6260
7406
|
{
|
|
@@ -6273,6 +7419,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6273
7419
|
},
|
|
6274
7420
|
},
|
|
6275
7421
|
required: ['to', 'context'],
|
|
7422
|
+
additionalProperties: false,
|
|
6276
7423
|
},
|
|
6277
7424
|
},
|
|
6278
7425
|
{
|
|
@@ -6295,173 +7442,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6295
7442
|
},
|
|
6296
7443
|
},
|
|
6297
7444
|
required: ['file_path'],
|
|
7445
|
+
additionalProperties: false,
|
|
6298
7446
|
},
|
|
6299
7447
|
},
|
|
7448
|
+
// --- Task tools (from tools/tasks.js) ---
|
|
7449
|
+
...tasks.definitions,
|
|
7450
|
+
// --- Knowledge tools (from tools/knowledge.js) ---
|
|
7451
|
+
...knowledge.definitions,
|
|
6300
7452
|
{
|
|
6301
|
-
name: '
|
|
6302
|
-
description: '
|
|
6303
|
-
inputSchema: {
|
|
6304
|
-
type: 'object',
|
|
6305
|
-
properties: {
|
|
6306
|
-
title: { type: 'string', description: 'Short task title' },
|
|
6307
|
-
description: { type: 'string', description: 'Detailed task description' },
|
|
6308
|
-
assignee: { type: 'string', description: 'Agent to assign to (optional, auto-assigns with 2 agents)' },
|
|
6309
|
-
},
|
|
6310
|
-
required: ['title'],
|
|
6311
|
-
},
|
|
6312
|
-
},
|
|
6313
|
-
{
|
|
6314
|
-
name: 'update_task',
|
|
6315
|
-
description: 'Update a task status. Statuses: pending, in_progress, in_review, done, blocked.',
|
|
6316
|
-
inputSchema: {
|
|
6317
|
-
type: 'object',
|
|
6318
|
-
properties: {
|
|
6319
|
-
task_id: { type: 'string', description: 'Task ID to update' },
|
|
6320
|
-
status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'blocked'], description: 'New status' },
|
|
6321
|
-
notes: { type: 'string', description: 'Optional progress note' },
|
|
6322
|
-
},
|
|
6323
|
-
required: ['task_id', 'status'],
|
|
6324
|
-
},
|
|
6325
|
-
},
|
|
6326
|
-
{
|
|
6327
|
-
name: 'list_tasks',
|
|
6328
|
-
description: 'List all tasks, optionally filtered by status or assignee.',
|
|
6329
|
-
inputSchema: {
|
|
6330
|
-
type: 'object',
|
|
6331
|
-
properties: {
|
|
6332
|
-
status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'blocked'], description: 'Filter by status' },
|
|
6333
|
-
assignee: { type: 'string', description: 'Filter by assignee agent name' },
|
|
6334
|
-
},
|
|
6335
|
-
},
|
|
6336
|
-
},
|
|
6337
|
-
{
|
|
6338
|
-
name: 'get_summary',
|
|
6339
|
-
description: 'Get a condensed summary of the conversation so far. Useful when context is getting long and you need a quick recap of what was discussed.',
|
|
6340
|
-
inputSchema: {
|
|
6341
|
-
type: 'object',
|
|
6342
|
-
properties: {
|
|
6343
|
-
last_n: {
|
|
6344
|
-
type: 'number',
|
|
6345
|
-
description: 'Number of recent messages to summarize (default: 20)',
|
|
6346
|
-
},
|
|
6347
|
-
},
|
|
6348
|
-
},
|
|
6349
|
-
},
|
|
6350
|
-
{
|
|
6351
|
-
name: 'search_messages',
|
|
6352
|
-
description: 'Search conversation history by keyword. Returns matching messages with previews. Useful for finding past discussions, decisions, or code references.',
|
|
6353
|
-
inputSchema: {
|
|
6354
|
-
type: 'object',
|
|
6355
|
-
properties: {
|
|
6356
|
-
query: { type: 'string', description: 'Search term (min 2 chars)' },
|
|
6357
|
-
from: { type: 'string', description: 'Filter by sender agent name (optional)' },
|
|
6358
|
-
limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
|
|
6359
|
-
},
|
|
6360
|
-
required: ['query'],
|
|
6361
|
-
},
|
|
6362
|
-
},
|
|
6363
|
-
{
|
|
6364
|
-
name: 'reset',
|
|
6365
|
-
description: 'Clear all data files and start fresh. Automatically archives the conversation before clearing.',
|
|
7453
|
+
name: 'reset',
|
|
7454
|
+
description: 'Clear all data files and start fresh. Automatically archives the conversation before clearing.',
|
|
6366
7455
|
inputSchema: {
|
|
6367
7456
|
type: 'object',
|
|
6368
7457
|
properties: {},
|
|
7458
|
+
additionalProperties: false,
|
|
6369
7459
|
},
|
|
6370
7460
|
},
|
|
6371
|
-
// ---
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
inputSchema: {
|
|
6376
|
-
type: 'object',
|
|
6377
|
-
properties: {
|
|
6378
|
-
display_name: { type: 'string', description: 'Display name (max 30 chars)' },
|
|
6379
|
-
avatar: { type: 'string', description: 'Avatar URL or data URI (max 64KB)' },
|
|
6380
|
-
bio: { type: 'string', description: 'Short bio (max 200 chars)' },
|
|
6381
|
-
role: { type: 'string', description: 'Role/title (max 30 chars, e.g. "Architect", "Reviewer")' },
|
|
6382
|
-
},
|
|
6383
|
-
},
|
|
6384
|
-
},
|
|
6385
|
-
// --- Phase 2: Workspaces ---
|
|
6386
|
-
{
|
|
6387
|
-
name: 'workspace_write',
|
|
6388
|
-
description: 'Write a key-value entry to your workspace. Other agents can read your workspace but only you can write to it. Max 50 keys, 100KB per value.',
|
|
6389
|
-
inputSchema: {
|
|
6390
|
-
type: 'object',
|
|
6391
|
-
properties: {
|
|
6392
|
-
key: { type: 'string', description: 'Key name (1-50 alphanumeric/underscore/hyphen/dot chars)' },
|
|
6393
|
-
content: { type: 'string', description: 'Content to store (max 100KB)' },
|
|
6394
|
-
},
|
|
6395
|
-
required: ['key', 'content'],
|
|
6396
|
-
},
|
|
6397
|
-
},
|
|
6398
|
-
{
|
|
6399
|
-
name: 'workspace_read',
|
|
6400
|
-
description: 'Read workspace entries. Read your own or another agent\'s workspace. Omit key to read all entries.',
|
|
6401
|
-
inputSchema: {
|
|
6402
|
-
type: 'object',
|
|
6403
|
-
properties: {
|
|
6404
|
-
key: { type: 'string', description: 'Specific key to read (optional — omit for all keys)' },
|
|
6405
|
-
agent: { type: 'string', description: 'Agent whose workspace to read (optional — defaults to yourself)' },
|
|
6406
|
-
},
|
|
6407
|
-
},
|
|
6408
|
-
},
|
|
6409
|
-
{
|
|
6410
|
-
name: 'workspace_list',
|
|
6411
|
-
description: 'List workspace keys. Specify agent for one workspace, or omit for all agents\' workspace summaries.',
|
|
6412
|
-
inputSchema: {
|
|
6413
|
-
type: 'object',
|
|
6414
|
-
properties: {
|
|
6415
|
-
agent: { type: 'string', description: 'Agent name (optional — omit for all)' },
|
|
6416
|
-
},
|
|
6417
|
-
},
|
|
6418
|
-
},
|
|
6419
|
-
// --- Phase 3: Workflows ---
|
|
6420
|
-
{
|
|
6421
|
-
name: 'create_workflow',
|
|
6422
|
-
description: 'Create a multi-step workflow pipeline. Each step can have a description, assignee, and depends_on (step IDs). Set autonomous=true for proactive work loop (agents auto-advance, no human gates). Set parallel=true to run independent steps simultaneously.',
|
|
6423
|
-
inputSchema: {
|
|
6424
|
-
type: 'object',
|
|
6425
|
-
properties: {
|
|
6426
|
-
name: { type: 'string', description: 'Workflow name (max 50 chars)' },
|
|
6427
|
-
steps: {
|
|
6428
|
-
type: 'array',
|
|
6429
|
-
description: 'Array of steps. Each step is a string (description) or {description, assignee, depends_on: [stepIds]}.',
|
|
6430
|
-
items: {
|
|
6431
|
-
oneOf: [
|
|
6432
|
-
{ type: 'string' },
|
|
6433
|
-
{ type: 'object', properties: { description: { type: 'string' }, assignee: { type: 'string' }, depends_on: { type: 'array', items: { type: 'number' }, description: 'Step IDs this step depends on (must complete first)' } }, required: ['description'] },
|
|
6434
|
-
],
|
|
6435
|
-
},
|
|
6436
|
-
},
|
|
6437
|
-
autonomous: { type: 'boolean', default: false, description: 'If true, agents auto-advance through steps without waiting for approval. Enables proactive work loop, relaxed send limits, fast cooldowns, and 30s listen cap.' },
|
|
6438
|
-
parallel: { type: 'boolean', default: false, description: 'If true, steps with met dependencies run in parallel (multiple agents work simultaneously)' },
|
|
6439
|
-
},
|
|
6440
|
-
required: ['name', 'steps'],
|
|
6441
|
-
},
|
|
6442
|
-
},
|
|
6443
|
-
{
|
|
6444
|
-
name: 'advance_workflow',
|
|
6445
|
-
description: 'Mark the current step as done and start the next step. Auto-sends a handoff message to the next assignee.',
|
|
6446
|
-
inputSchema: {
|
|
6447
|
-
type: 'object',
|
|
6448
|
-
properties: {
|
|
6449
|
-
workflow_id: { type: 'string', description: 'Workflow ID' },
|
|
6450
|
-
notes: { type: 'string', description: 'Optional completion notes (max 500 chars)' },
|
|
6451
|
-
},
|
|
6452
|
-
required: ['workflow_id'],
|
|
6453
|
-
},
|
|
6454
|
-
},
|
|
6455
|
-
{
|
|
6456
|
-
name: 'workflow_status',
|
|
6457
|
-
description: 'Get status of a specific workflow or all workflows. Shows step progress and completion percentage.',
|
|
6458
|
-
inputSchema: {
|
|
6459
|
-
type: 'object',
|
|
6460
|
-
properties: {
|
|
6461
|
-
workflow_id: { type: 'string', description: 'Workflow ID (optional — omit for all workflows)' },
|
|
6462
|
-
},
|
|
6463
|
-
},
|
|
6464
|
-
},
|
|
7461
|
+
// --- System tools (from tools/system.js): profiles, workspaces, branches, reputation ---
|
|
7462
|
+
...system.definitions,
|
|
7463
|
+
// --- Workflow tools (from tools/workflows.js) ---
|
|
7464
|
+
...workflows.definitions,
|
|
6465
7465
|
// --- Phase 4: Branching ---
|
|
6466
7466
|
{
|
|
6467
7467
|
name: 'fork_conversation',
|
|
@@ -6473,6 +7473,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6473
7473
|
branch_name: { type: 'string', description: 'Name for the new branch (1-20 alphanumeric chars)' },
|
|
6474
7474
|
},
|
|
6475
7475
|
required: ['branch_name'],
|
|
7476
|
+
additionalProperties: false,
|
|
6476
7477
|
},
|
|
6477
7478
|
},
|
|
6478
7479
|
{
|
|
@@ -6484,16 +7485,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6484
7485
|
branch_name: { type: 'string', description: 'Branch to switch to' },
|
|
6485
7486
|
},
|
|
6486
7487
|
required: ['branch_name'],
|
|
7488
|
+
additionalProperties: false,
|
|
6487
7489
|
},
|
|
6488
7490
|
},
|
|
6489
|
-
|
|
6490
|
-
name: 'list_branches',
|
|
6491
|
-
description: 'List all conversation branches with message counts and metadata.',
|
|
6492
|
-
inputSchema: {
|
|
6493
|
-
type: 'object',
|
|
6494
|
-
properties: {},
|
|
6495
|
-
},
|
|
6496
|
-
},
|
|
7491
|
+
// list_branches included via ...system.definitions above
|
|
6497
7492
|
{
|
|
6498
7493
|
name: 'set_conversation_mode',
|
|
6499
7494
|
description: 'Switch between "direct" (point-to-point), "group" (free multi-agent chat with auto-broadcast), or "managed" (structured turn-taking with a manager who controls who speaks). Use managed mode for 3+ agent teams to prevent chaos.',
|
|
@@ -6503,183 +7498,30 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6503
7498
|
mode: { type: 'string', description: '"direct" (default), "group" for free chat, or "managed" for structured turn-taking', enum: ['group', 'direct', 'managed'] },
|
|
6504
7499
|
},
|
|
6505
7500
|
required: ['mode'],
|
|
7501
|
+
additionalProperties: false,
|
|
6506
7502
|
},
|
|
6507
7503
|
},
|
|
6508
|
-
|
|
6509
|
-
|
|
6510
|
-
description: 'Listen for messages in group or managed conversation mode. Auto-detects mode: in direct mode, behaves like listen(). Returns ALL unconsumed messages as a sorted batch (system > threaded > direct > broadcast), plus batch_summary, agent statuses, and hints. Either listen() or listen_group() works in any mode — they auto-delegate. Call again immediately after responding.',
|
|
6511
|
-
inputSchema: {
|
|
6512
|
-
type: 'object',
|
|
6513
|
-
properties: {},
|
|
6514
|
-
},
|
|
6515
|
-
},
|
|
6516
|
-
// --- Channels ---
|
|
6517
|
-
{
|
|
6518
|
-
name: 'join_channel',
|
|
6519
|
-
description: 'Join or create a channel. Channels let sub-teams communicate without flooding the main conversation. Auto-joined to #general on register. Use channels when team size > 4.',
|
|
6520
|
-
inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel name (1-20 chars, e.g. "backend", "testing")' }, description: { type: 'string', description: 'Channel description (optional, max 200 chars)' }, rate_limit: { type: 'object', description: 'Optional rate limit config: { max_sends_per_minute: 10 }. Any member can update.', properties: { max_sends_per_minute: { type: 'number' } } } }, required: ['name'] },
|
|
6521
|
-
},
|
|
6522
|
-
{
|
|
6523
|
-
name: 'leave_channel',
|
|
6524
|
-
description: 'Leave a channel. You will stop receiving messages from it. Cannot leave #general.',
|
|
6525
|
-
inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel to leave' } }, required: ['name'] },
|
|
6526
|
-
},
|
|
6527
|
-
{
|
|
6528
|
-
name: 'list_channels',
|
|
6529
|
-
description: 'List all channels with members, message counts, and your membership status.',
|
|
6530
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6531
|
-
},
|
|
7504
|
+
// --- Channel tools (from tools/channels.js) ---
|
|
7505
|
+
...channels.definitions,
|
|
6532
7506
|
// --- Briefing & Recovery ---
|
|
6533
7507
|
{
|
|
6534
7508
|
name: 'get_guide',
|
|
6535
7509
|
description: 'Get the collaboration guide — all tool categories, critical rules, and workflow patterns. Call this if you are unsure how to use the tools or need a refresher on best practices. Use level="minimal" for a compact refresher (saves context tokens), "full" for complete reference with tool details.',
|
|
6536
|
-
inputSchema: { type: 'object', properties: { level: { type: 'string', enum: ['minimal', 'standard', 'full'], description: 'Guide detail level: "minimal" (~5 rules, saves tokens), "standard" (default, progressive disclosure), "full" (all rules + tool details)' } } },
|
|
6537
|
-
},
|
|
6538
|
-
{
|
|
6539
|
-
name: 'get_briefing',
|
|
6540
|
-
description: 'Get a full project briefing: who is online, active tasks, recent decisions, knowledge base, locked files, progress, and project files. Call this when joining a project or after being away. One call = fully onboarded.',
|
|
6541
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6542
|
-
},
|
|
6543
|
-
// --- File Locking ---
|
|
6544
|
-
{
|
|
6545
|
-
name: 'lock_file',
|
|
6546
|
-
description: 'Lock a file for exclusive editing. Other agents will be warned if they try to edit it. Call unlock_file() when done. Locks auto-release if you disconnect.',
|
|
6547
|
-
inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Relative path to the file to lock' } }, required: ['file_path'] },
|
|
6548
|
-
},
|
|
6549
|
-
{
|
|
6550
|
-
name: 'unlock_file',
|
|
6551
|
-
description: 'Unlock a file you previously locked. Omit file_path to unlock all your files.',
|
|
6552
|
-
inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'File to unlock (optional — omit to unlock all)' } } },
|
|
6553
|
-
},
|
|
6554
|
-
// --- Decision Log ---
|
|
6555
|
-
{
|
|
6556
|
-
name: 'log_decision',
|
|
6557
|
-
description: 'Log a team decision so it persists and other agents can reference it. Prevents re-debating the same choices.',
|
|
6558
|
-
inputSchema: { type: 'object', properties: { decision: { type: 'string', description: 'The decision made (max 500 chars)' }, reasoning: { type: 'string', description: 'Why this was decided (optional, max 1000 chars)' }, topic: { type: 'string', description: 'Category like "architecture", "tech-stack", "design" (optional)' } }, required: ['decision'] },
|
|
6559
|
-
},
|
|
6560
|
-
{
|
|
6561
|
-
name: 'get_decisions',
|
|
6562
|
-
description: 'Get all logged decisions, optionally filtered by topic.',
|
|
6563
|
-
inputSchema: { type: 'object', properties: { topic: { type: 'string', description: 'Filter by topic (optional)' } } },
|
|
6564
|
-
},
|
|
6565
|
-
// --- Knowledge Base ---
|
|
6566
|
-
{
|
|
6567
|
-
name: 'kb_write',
|
|
6568
|
-
description: 'Write to the shared team knowledge base. Any agent can read, any agent can write. Use for API specs, conventions, shared data.',
|
|
6569
|
-
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key name (1-50 alphanumeric chars)' }, content: { type: 'string', description: 'Content (max 100KB)' } }, required: ['key', 'content'] },
|
|
6570
|
-
},
|
|
6571
|
-
{
|
|
6572
|
-
name: 'kb_read',
|
|
6573
|
-
description: 'Read from the shared knowledge base. Omit key to read all entries.',
|
|
6574
|
-
inputSchema: { type: 'object', properties: { key: { type: 'string', description: 'Key to read (optional — omit for all)' } } },
|
|
6575
|
-
},
|
|
6576
|
-
{
|
|
6577
|
-
name: 'kb_list',
|
|
6578
|
-
description: 'List all keys in the shared knowledge base with metadata.',
|
|
6579
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6580
|
-
},
|
|
6581
|
-
// --- Progress Tracking ---
|
|
6582
|
-
{
|
|
6583
|
-
name: 'update_progress',
|
|
6584
|
-
description: 'Update feature-level progress. Higher level than tasks — tracks overall feature completion percentage.',
|
|
6585
|
-
inputSchema: { type: 'object', properties: { feature: { type: 'string', description: 'Feature name (max 100 chars)' }, percent: { type: 'number', description: 'Completion percentage 0-100' }, notes: { type: 'string', description: 'Progress notes (optional)' } }, required: ['feature', 'percent'] },
|
|
6586
|
-
},
|
|
6587
|
-
{
|
|
6588
|
-
name: 'get_progress',
|
|
6589
|
-
description: 'Get progress on all features with completion percentages and overall project progress.',
|
|
6590
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6591
|
-
},
|
|
6592
|
-
// --- Voting ---
|
|
6593
|
-
{
|
|
6594
|
-
name: 'call_vote',
|
|
6595
|
-
description: 'Start a vote for the team to decide something. All online agents are notified and can cast their vote.',
|
|
6596
|
-
inputSchema: { type: 'object', properties: { question: { type: 'string', description: 'The question to vote on' }, options: { type: 'array', items: { type: 'string' }, description: 'Array of 2-10 options to choose from' } }, required: ['question', 'options'] },
|
|
6597
|
-
},
|
|
6598
|
-
{
|
|
6599
|
-
name: 'cast_vote',
|
|
6600
|
-
description: 'Cast your vote on an open vote. Vote auto-resolves when all online agents have voted.',
|
|
6601
|
-
inputSchema: { type: 'object', properties: { vote_id: { type: 'string', description: 'Vote ID' }, choice: { type: 'string', description: 'Your choice (must match one of the options)' } }, required: ['vote_id', 'choice'] },
|
|
6602
|
-
},
|
|
6603
|
-
{
|
|
6604
|
-
name: 'vote_status',
|
|
6605
|
-
description: 'Check status of a specific vote or all votes.',
|
|
6606
|
-
inputSchema: { type: 'object', properties: { vote_id: { type: 'string', description: 'Vote ID (optional — omit for all)' } } },
|
|
6607
|
-
},
|
|
6608
|
-
// --- Code Review ---
|
|
6609
|
-
{
|
|
6610
|
-
name: 'request_review',
|
|
6611
|
-
description: 'Request a code review from the team. Creates a review request and notifies all agents.',
|
|
6612
|
-
inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'File to review' }, description: { type: 'string', description: 'What to focus on in the review' } }, required: ['file_path'] },
|
|
6613
|
-
},
|
|
6614
|
-
{
|
|
6615
|
-
name: 'submit_review',
|
|
6616
|
-
description: 'Submit a code review — approve or request changes with feedback.',
|
|
6617
|
-
inputSchema: { type: 'object', properties: { review_id: { type: 'string', description: 'Review ID' }, status: { type: 'string', enum: ['approved', 'changes_requested'], description: 'Review result' }, feedback: { type: 'string', description: 'Your review feedback (max 2000 chars)' } }, required: ['review_id', 'status'] },
|
|
6618
|
-
},
|
|
6619
|
-
// --- Dependencies ---
|
|
6620
|
-
{
|
|
6621
|
-
name: 'declare_dependency',
|
|
6622
|
-
description: 'Declare that a task depends on another task. You will be notified when the dependency is complete.',
|
|
6623
|
-
inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'Your task that is blocked' }, depends_on: { type: 'string', description: 'Task ID that must complete first' } }, required: ['task_id', 'depends_on'] },
|
|
6624
|
-
},
|
|
6625
|
-
{
|
|
6626
|
-
name: 'check_dependencies',
|
|
6627
|
-
description: 'Check dependency status for a task or all unresolved dependencies.',
|
|
6628
|
-
inputSchema: { type: 'object', properties: { task_id: { type: 'string', description: 'Task ID to check (optional — omit for all unresolved)' } } },
|
|
6629
|
-
},
|
|
6630
|
-
// --- Conversation Compression ---
|
|
6631
|
-
{
|
|
6632
|
-
name: 'get_compressed_history',
|
|
6633
|
-
description: 'Get conversation history with automatic compression. Old messages are summarized into segments, recent messages shown verbatim. Use this when the conversation is long and you need to catch up without overflowing your context.',
|
|
6634
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6635
|
-
},
|
|
6636
|
-
// --- Reputation ---
|
|
6637
|
-
{
|
|
6638
|
-
name: 'get_reputation',
|
|
6639
|
-
description: 'View agent reputation — tasks completed, reviews done, bugs found, strengths. Shows leaderboard when called without agent name.',
|
|
6640
|
-
inputSchema: { type: 'object', properties: { agent: { type: 'string', description: 'Agent name (optional — omit for leaderboard)' } } },
|
|
6641
|
-
},
|
|
6642
|
-
{
|
|
6643
|
-
name: 'suggest_task',
|
|
6644
|
-
description: 'Get a task suggestion based on your strengths, pending tasks, open reviews, and blocked dependencies. Helps you find the most useful thing to do next.',
|
|
6645
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6646
|
-
},
|
|
6647
|
-
// --- Rules tools ---
|
|
6648
|
-
{
|
|
6649
|
-
name: 'add_rule',
|
|
6650
|
-
description: 'Add a project rule that all agents must follow. Rules appear in every agent\'s guide and briefing. Categories: safety, workflow, code-style, communication, custom.',
|
|
6651
|
-
inputSchema: {
|
|
6652
|
-
type: 'object',
|
|
6653
|
-
properties: {
|
|
6654
|
-
text: { type: 'string', description: 'The rule text' },
|
|
6655
|
-
category: { type: 'string', description: 'Rule category: safety, workflow, code-style, communication, custom' },
|
|
6656
|
-
},
|
|
6657
|
-
required: ['text'],
|
|
6658
|
-
},
|
|
6659
|
-
},
|
|
6660
|
-
{
|
|
6661
|
-
name: 'list_rules',
|
|
6662
|
-
description: 'List all project rules (active and inactive count).',
|
|
6663
|
-
inputSchema: { type: 'object', properties: {} },
|
|
6664
|
-
},
|
|
6665
|
-
{
|
|
6666
|
-
name: 'remove_rule',
|
|
6667
|
-
description: 'Remove a project rule by ID.',
|
|
6668
|
-
inputSchema: {
|
|
6669
|
-
type: 'object',
|
|
6670
|
-
properties: { rule_id: { type: 'string', description: 'The rule ID to remove' } },
|
|
6671
|
-
required: ['rule_id'],
|
|
6672
|
-
},
|
|
6673
|
-
},
|
|
6674
|
-
{
|
|
6675
|
-
name: 'toggle_rule',
|
|
6676
|
-
description: 'Toggle a rule active/inactive without deleting it.',
|
|
6677
|
-
inputSchema: {
|
|
6678
|
-
type: 'object',
|
|
6679
|
-
properties: { rule_id: { type: 'string', description: 'The rule ID to toggle' } },
|
|
6680
|
-
required: ['rule_id'],
|
|
6681
|
-
},
|
|
7510
|
+
inputSchema: { type: 'object', properties: { level: { type: 'string', enum: ['minimal', 'standard', 'full'], description: 'Guide detail level: "minimal" (~5 rules, saves tokens), "standard" (default, progressive disclosure), "full" (all rules + tool details)' } } , additionalProperties: false},
|
|
6682
7511
|
},
|
|
7512
|
+
// get_briefing, lock_file, unlock_file, log_decision, get_decisions, kb_*, progress_*
|
|
7513
|
+
// are included via ...knowledge.definitions and ...safety.definitions
|
|
7514
|
+
// --- Safety tools (from tools/safety.js) ---
|
|
7515
|
+
...safety.definitions,
|
|
7516
|
+
// --- Hook tools (from tools/hooks.js) ---
|
|
7517
|
+
...hooks.definitions,
|
|
7518
|
+
// --- Governance tools (from tools/governance.js) ---
|
|
7519
|
+
...governance.definitions,
|
|
7520
|
+
// declare_dependency, check_dependencies included via ...safety.definitions
|
|
7521
|
+
// get_compressed_history included via ...knowledge.definitions
|
|
7522
|
+
// get_reputation included via ...system.definitions above
|
|
7523
|
+
// suggest_task is included via ...tasks.definitions above
|
|
7524
|
+
// Rules, audit, and push tools are included via ...governance.definitions above
|
|
6683
7525
|
// --- Autonomy Engine tools ---
|
|
6684
7526
|
{
|
|
6685
7527
|
name: 'get_work',
|
|
@@ -6690,6 +7532,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6690
7532
|
just_completed: { type: 'string', description: 'What you just finished (for context continuity)' },
|
|
6691
7533
|
available_skills: { type: 'array', items: { type: 'string' }, description: 'What you are good at (e.g., "backend", "testing", "frontend")' },
|
|
6692
7534
|
},
|
|
7535
|
+
additionalProperties: false,
|
|
6693
7536
|
},
|
|
6694
7537
|
},
|
|
6695
7538
|
{
|
|
@@ -6706,6 +7549,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6706
7549
|
learnings: { type: 'string', description: 'What you learned that could help future work' },
|
|
6707
7550
|
},
|
|
6708
7551
|
required: ['workflow_id', 'summary', 'verification', 'confidence'],
|
|
7552
|
+
additionalProperties: false,
|
|
6709
7553
|
},
|
|
6710
7554
|
},
|
|
6711
7555
|
{
|
|
@@ -6721,6 +7565,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6721
7565
|
attempt_number: { type: 'number', description: 'Which retry this is (1, 2, or 3)' },
|
|
6722
7566
|
},
|
|
6723
7567
|
required: ['task_or_step', 'what_failed', 'why_it_failed', 'new_approach'],
|
|
7568
|
+
additionalProperties: false,
|
|
6724
7569
|
},
|
|
6725
7570
|
},
|
|
6726
7571
|
{
|
|
@@ -6747,6 +7592,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6747
7592
|
parallel: { type: 'boolean', description: 'Allow parallel execution of independent steps (default: true)' },
|
|
6748
7593
|
},
|
|
6749
7594
|
required: ['name', 'steps'],
|
|
7595
|
+
additionalProperties: false,
|
|
6750
7596
|
},
|
|
6751
7597
|
},
|
|
6752
7598
|
{
|
|
@@ -6758,6 +7604,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6758
7604
|
content: { type: 'string', description: 'The user request or prompt to distribute' },
|
|
6759
7605
|
},
|
|
6760
7606
|
required: ['content'],
|
|
7607
|
+
additionalProperties: false,
|
|
6761
7608
|
},
|
|
6762
7609
|
},
|
|
6763
7610
|
// --- Managed mode tools ---
|
|
@@ -6767,6 +7614,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6767
7614
|
inputSchema: {
|
|
6768
7615
|
type: 'object',
|
|
6769
7616
|
properties: {},
|
|
7617
|
+
additionalProperties: false,
|
|
6770
7618
|
},
|
|
6771
7619
|
},
|
|
6772
7620
|
{
|
|
@@ -6779,6 +7627,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6779
7627
|
prompt: { type: 'string', description: 'Optional question or topic for the agent to respond to' },
|
|
6780
7628
|
},
|
|
6781
7629
|
required: ['to'],
|
|
7630
|
+
additionalProperties: false,
|
|
6782
7631
|
},
|
|
6783
7632
|
},
|
|
6784
7633
|
{
|
|
@@ -6790,6 +7639,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6790
7639
|
phase: { type: 'string', description: 'Phase name', enum: ['discussion', 'planning', 'execution', 'review'] },
|
|
6791
7640
|
},
|
|
6792
7641
|
required: ['phase'],
|
|
7642
|
+
additionalProperties: false,
|
|
6793
7643
|
},
|
|
6794
7644
|
},
|
|
6795
7645
|
],
|
|
@@ -6798,19 +7648,79 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
6798
7648
|
|
|
6799
7649
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
6800
7650
|
const { name, arguments: args } = request.params;
|
|
7651
|
+
const startTime = Date.now();
|
|
6801
7652
|
|
|
6802
7653
|
try {
|
|
7654
|
+
// Escalating listen() enforcement — block tools after too many non-listen calls
|
|
7655
|
+
// send_message is exempt so blocked agents can escalate to coordinator before calling listen()
|
|
7656
|
+
// messages is exempt (unified query tool — replaces check_messages/consume_messages)
|
|
7657
|
+
const listenExemptTools = new Set(['register', 'get_briefing', 'get_guide', 'listen', 'wait_for_reply', 'update_profile', 'list_agents', 'add_rule', 'remove_rule', 'toggle_rule', 'list_rules', 'send_message', 'messages']);
|
|
7658
|
+
if (listenExemptTools.has(name)) {
|
|
7659
|
+
if (name === 'listen' || name === 'wait_for_reply') {
|
|
7660
|
+
consecutiveNonListenCalls = 0;
|
|
7661
|
+
}
|
|
7662
|
+
} else if (registeredName) {
|
|
7663
|
+
// Exempt Coordinator (lead role) from listen() blocking — in "responsive" mode
|
|
7664
|
+
// Coordinators use check_messages/consume_messages instead of listen()
|
|
7665
|
+
const isCoordinatorExempt = (() => {
|
|
7666
|
+
try {
|
|
7667
|
+
const profiles = getProfiles();
|
|
7668
|
+
const myProfile = profiles[registeredName];
|
|
7669
|
+
if (myProfile && myProfile.role === 'lead') {
|
|
7670
|
+
const coordMode = (getConfig().coordinator_mode || 'responsive');
|
|
7671
|
+
return coordMode === 'responsive';
|
|
7672
|
+
}
|
|
7673
|
+
} catch (_) { /* fall through */ }
|
|
7674
|
+
return false;
|
|
7675
|
+
})();
|
|
7676
|
+
|
|
7677
|
+
if (!isCoordinatorExempt) {
|
|
7678
|
+
consecutiveNonListenCalls++;
|
|
7679
|
+
if (consecutiveNonListenCalls >= 5) {
|
|
7680
|
+
const coordinator = (() => {
|
|
7681
|
+
try {
|
|
7682
|
+
const profs = getProfiles();
|
|
7683
|
+
const lead = Object.entries(profs).find(([, p]) => p.role === 'lead' || p.role === 'Coordinator');
|
|
7684
|
+
return lead ? lead[0] : 'your coordinator';
|
|
7685
|
+
} catch { return 'your coordinator'; }
|
|
7686
|
+
})();
|
|
7687
|
+
return {
|
|
7688
|
+
content: [{ type: 'text', text: JSON.stringify({
|
|
7689
|
+
error: `BLOCKED: You must call listen() before using other tools. You have made ${consecutiveNonListenCalls} tool calls without listening. Call listen() now.`,
|
|
7690
|
+
blocked_tool: name,
|
|
7691
|
+
calls_without_listen: consecutiveNonListenCalls,
|
|
7692
|
+
fix: `1. Call send_message(to='${coordinator}', content='BLOCKED: I made ${consecutiveNonListenCalls} tool calls without listen(). I was trying to call ${name}. Requesting instructions — should I proceed?') 2. Then call listen() immediately to unblock all tools.`,
|
|
7693
|
+
_listen: 'After send_message(), call listen() immediately. It will reset the counter and unblock all tools.',
|
|
7694
|
+
}, null, 2) }],
|
|
7695
|
+
isError: true,
|
|
7696
|
+
};
|
|
7697
|
+
}
|
|
7698
|
+
}
|
|
7699
|
+
}
|
|
7700
|
+
|
|
7701
|
+
// Middleware: deterministic agent status tracking before each tool call
|
|
7702
|
+
if (registeredName) {
|
|
7703
|
+
const _listenTools = new Set(['listen', 'wait_for_reply']);
|
|
7704
|
+
const _agents = getAgents();
|
|
7705
|
+
if (_agents[registeredName]) {
|
|
7706
|
+
_agents[registeredName].status = _listenTools.has(name) ? 'listening' : 'working';
|
|
7707
|
+
_agents[registeredName].current_tool = name;
|
|
7708
|
+
_agents[registeredName].last_activity = new Date().toISOString();
|
|
7709
|
+
saveAgents(_agents);
|
|
7710
|
+
}
|
|
7711
|
+
}
|
|
7712
|
+
|
|
6803
7713
|
let result;
|
|
6804
7714
|
|
|
6805
7715
|
switch (name) {
|
|
6806
7716
|
case 'register':
|
|
6807
|
-
result = toolRegister(args.name, args?.provider);
|
|
7717
|
+
result = toolRegister(args.name, args?.provider, args?.skills);
|
|
6808
7718
|
break;
|
|
6809
7719
|
case 'list_agents':
|
|
6810
7720
|
result = toolListAgents();
|
|
6811
7721
|
break;
|
|
6812
7722
|
case 'send_message':
|
|
6813
|
-
result = await toolSendMessage(args.content, args?.to, args?.reply_to, args?.channel);
|
|
7723
|
+
result = await toolSendMessage(args.content, args?.to, args?.reply_to, args?.channel, args?.priority);
|
|
6814
7724
|
break;
|
|
6815
7725
|
case 'wait_for_reply':
|
|
6816
7726
|
result = await toolWaitForReply(args?.timeout_seconds, args?.from);
|
|
@@ -6819,28 +7729,31 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6819
7729
|
result = toolBroadcast(args.content);
|
|
6820
7730
|
break;
|
|
6821
7731
|
case 'listen':
|
|
6822
|
-
result = await toolListen(args?.from);
|
|
6823
|
-
break;
|
|
6824
|
-
case 'listen_codex':
|
|
6825
|
-
result = await toolListenCodex(args?.from);
|
|
6826
|
-
break;
|
|
6827
|
-
case 'check_messages':
|
|
6828
|
-
result = toolCheckMessages(args?.from);
|
|
7732
|
+
result = await toolListen(args?.from, args?.outcome, args?.task_id, args?.summary, args?.mode);
|
|
6829
7733
|
break;
|
|
6830
|
-
case '
|
|
6831
|
-
|
|
6832
|
-
|
|
6833
|
-
|
|
6834
|
-
|
|
7734
|
+
case 'messages': {
|
|
7735
|
+
// Unified message management — routes by action param
|
|
7736
|
+
const action = (args || {}).action;
|
|
7737
|
+
const actionMap = {
|
|
7738
|
+
check: 'check_messages',
|
|
7739
|
+
consume: 'consume_messages',
|
|
7740
|
+
history: 'get_history',
|
|
7741
|
+
search: 'search_messages',
|
|
7742
|
+
ack: 'ack_message',
|
|
7743
|
+
notifications: 'get_notifications',
|
|
7744
|
+
};
|
|
7745
|
+
const target = actionMap[action];
|
|
7746
|
+
if (!target) {
|
|
7747
|
+
result = { error: `Unknown action "${action}". Must be one of: check, consume, history, search, ack, notifications` };
|
|
7748
|
+
} else {
|
|
7749
|
+
result = messaging.handlers[target](args || {});
|
|
7750
|
+
}
|
|
6835
7751
|
break;
|
|
7752
|
+
}
|
|
6836
7753
|
case 'create_task':
|
|
6837
|
-
result = toolCreateTask(args.title, args?.description, args?.assignee);
|
|
6838
|
-
break;
|
|
6839
7754
|
case 'update_task':
|
|
6840
|
-
result = toolUpdateTask(args.task_id, args.status, args?.notes);
|
|
6841
|
-
break;
|
|
6842
7755
|
case 'list_tasks':
|
|
6843
|
-
result =
|
|
7756
|
+
result = tasks.handlers[name](args || {});
|
|
6844
7757
|
break;
|
|
6845
7758
|
case 'handoff':
|
|
6846
7759
|
result = toolHandoff(args.to, args.context);
|
|
@@ -6849,34 +7762,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6849
7762
|
result = toolShareFile(args.file_path, args?.to, args?.summary);
|
|
6850
7763
|
break;
|
|
6851
7764
|
case 'get_summary':
|
|
6852
|
-
|
|
6853
|
-
|
|
6854
|
-
case '
|
|
6855
|
-
|
|
7765
|
+
case 'get_briefing':
|
|
7766
|
+
case 'log_decision':
|
|
7767
|
+
case 'get_decisions':
|
|
7768
|
+
case 'kb_write':
|
|
7769
|
+
case 'kb_read':
|
|
7770
|
+
case 'kb_list':
|
|
7771
|
+
case 'update_progress':
|
|
7772
|
+
case 'get_progress':
|
|
7773
|
+
case 'get_compressed_history':
|
|
7774
|
+
result = knowledge.handlers[name](args || {});
|
|
6856
7775
|
break;
|
|
6857
7776
|
case 'reset':
|
|
6858
7777
|
result = toolReset();
|
|
6859
7778
|
break;
|
|
6860
7779
|
case 'update_profile':
|
|
6861
|
-
result = toolUpdateProfile(args?.display_name, args?.avatar, args?.bio, args?.role);
|
|
6862
|
-
break;
|
|
6863
7780
|
case 'workspace_write':
|
|
6864
|
-
result = toolWorkspaceWrite(args.key, args.content);
|
|
6865
|
-
break;
|
|
6866
7781
|
case 'workspace_read':
|
|
6867
|
-
result = toolWorkspaceRead(args?.key, args?.agent);
|
|
6868
|
-
break;
|
|
6869
7782
|
case 'workspace_list':
|
|
6870
|
-
result =
|
|
7783
|
+
result = system.handlers[name](args || {});
|
|
6871
7784
|
break;
|
|
6872
7785
|
case 'create_workflow':
|
|
6873
|
-
result = toolCreateWorkflow(args.name, args.steps, args?.autonomous, args?.parallel);
|
|
6874
|
-
break;
|
|
6875
7786
|
case 'advance_workflow':
|
|
6876
|
-
result = toolAdvanceWorkflow(args.workflow_id, args?.notes);
|
|
6877
|
-
break;
|
|
6878
7787
|
case 'workflow_status':
|
|
6879
|
-
result =
|
|
7788
|
+
result = workflows.handlers[name](args || {});
|
|
6880
7789
|
break;
|
|
6881
7790
|
case 'fork_conversation':
|
|
6882
7791
|
result = toolForkConversation(args?.from_message_id, args.branch_name);
|
|
@@ -6885,97 +7794,68 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
6885
7794
|
result = toolSwitchBranch(args.branch_name);
|
|
6886
7795
|
break;
|
|
6887
7796
|
case 'list_branches':
|
|
6888
|
-
result =
|
|
7797
|
+
result = system.handlers[name](args || {});
|
|
6889
7798
|
break;
|
|
6890
7799
|
case 'set_conversation_mode':
|
|
6891
7800
|
result = toolSetConversationMode(args.mode);
|
|
6892
7801
|
break;
|
|
6893
|
-
case 'listen_group':
|
|
6894
|
-
result = await toolListenGroup();
|
|
6895
|
-
break;
|
|
6896
7802
|
case 'join_channel':
|
|
6897
|
-
result = toolJoinChannel(args.name, args?.description, args?.rate_limit);
|
|
6898
|
-
break;
|
|
6899
7803
|
case 'leave_channel':
|
|
6900
|
-
result = toolLeaveChannel(args.name);
|
|
6901
|
-
break;
|
|
6902
7804
|
case 'list_channels':
|
|
6903
|
-
result =
|
|
7805
|
+
result = channels.handlers[name](args || {});
|
|
6904
7806
|
break;
|
|
6905
7807
|
case 'get_guide':
|
|
6906
7808
|
result = toolGetGuide(args?.level);
|
|
6907
7809
|
break;
|
|
6908
|
-
|
|
6909
|
-
result = toolGetBriefing();
|
|
6910
|
-
break;
|
|
7810
|
+
// get_briefing, log_decision, get_decisions, kb_*, progress_* handled by knowledge module above
|
|
6911
7811
|
case 'lock_file':
|
|
6912
|
-
result = toolLockFile(args.file_path);
|
|
6913
|
-
break;
|
|
6914
7812
|
case 'unlock_file':
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
result = toolLogDecision(args.decision, args?.reasoning, args?.topic);
|
|
6919
|
-
break;
|
|
6920
|
-
case 'get_decisions':
|
|
6921
|
-
result = toolGetDecisions(args?.topic);
|
|
6922
|
-
break;
|
|
6923
|
-
case 'kb_write':
|
|
6924
|
-
result = toolKBWrite(args.key, args.content);
|
|
6925
|
-
break;
|
|
6926
|
-
case 'kb_read':
|
|
6927
|
-
result = toolKBRead(args?.key);
|
|
6928
|
-
break;
|
|
6929
|
-
case 'kb_list':
|
|
6930
|
-
result = toolKBList();
|
|
6931
|
-
break;
|
|
6932
|
-
case 'update_progress':
|
|
6933
|
-
result = toolUpdateProgress(args.feature, args.percent, args?.notes);
|
|
6934
|
-
break;
|
|
6935
|
-
case 'get_progress':
|
|
6936
|
-
result = toolGetProgress();
|
|
7813
|
+
case 'declare_dependency':
|
|
7814
|
+
case 'check_dependencies':
|
|
7815
|
+
result = safety.handlers[name](args || {});
|
|
6937
7816
|
break;
|
|
6938
7817
|
case 'call_vote':
|
|
6939
|
-
result = toolCallVote(args.question, args.options);
|
|
6940
|
-
break;
|
|
6941
7818
|
case 'cast_vote':
|
|
6942
|
-
result = toolCastVote(args.vote_id, args.choice);
|
|
6943
|
-
break;
|
|
6944
7819
|
case 'vote_status':
|
|
6945
|
-
result = toolVoteStatus(args?.vote_id);
|
|
6946
|
-
break;
|
|
6947
7820
|
case 'request_review':
|
|
6948
|
-
result = toolRequestReview(args.file_path, args?.description);
|
|
6949
|
-
break;
|
|
6950
7821
|
case 'submit_review':
|
|
6951
|
-
|
|
6952
|
-
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
|
|
6956
|
-
|
|
6957
|
-
result = toolCheckDependencies(args?.task_id);
|
|
6958
|
-
break;
|
|
6959
|
-
case 'get_compressed_history':
|
|
6960
|
-
result = toolGetCompressedHistory();
|
|
7822
|
+
// Route through governance module
|
|
7823
|
+
if (governance.handlers[name]) {
|
|
7824
|
+
result = governance.handlers[name](args || {});
|
|
7825
|
+
} else {
|
|
7826
|
+
result = { error: `Unknown governance tool: ${name}` };
|
|
7827
|
+
}
|
|
6961
7828
|
break;
|
|
7829
|
+
// declare_dependency, check_dependencies handled by safety module above
|
|
7830
|
+
// get_compressed_history handled by knowledge module above
|
|
6962
7831
|
case 'get_reputation':
|
|
6963
|
-
result =
|
|
7832
|
+
result = system.handlers[name](args || {});
|
|
7833
|
+
break;
|
|
7834
|
+
case 'subscribe_hook':
|
|
7835
|
+
case 'unsubscribe_hook':
|
|
7836
|
+
case 'list_hooks':
|
|
7837
|
+
result = hooks.handlers[name](args || {});
|
|
6964
7838
|
break;
|
|
6965
7839
|
case 'suggest_task':
|
|
6966
|
-
result =
|
|
7840
|
+
result = tasks.handlers[name](args || {});
|
|
6967
7841
|
break;
|
|
6968
7842
|
case 'add_rule':
|
|
6969
|
-
result = toolAddRule(args.text, args.category);
|
|
6970
|
-
break;
|
|
6971
7843
|
case 'list_rules':
|
|
6972
|
-
result = toolListRules();
|
|
6973
|
-
break;
|
|
6974
7844
|
case 'remove_rule':
|
|
6975
|
-
result = toolRemoveRule(args.rule_id);
|
|
6976
|
-
break;
|
|
6977
7845
|
case 'toggle_rule':
|
|
6978
|
-
|
|
7846
|
+
case 'log_violation':
|
|
7847
|
+
case 'request_push_approval':
|
|
7848
|
+
case 'ack_push':
|
|
7849
|
+
// Route all governance tools through the module
|
|
7850
|
+
if (governance.handlers[name]) {
|
|
7851
|
+
result = governance.handlers[name](args || {});
|
|
7852
|
+
// Push auto-approve timer
|
|
7853
|
+
if (name === 'request_push_approval' && result.request_id) {
|
|
7854
|
+
setTimeout(() => governance.checkPushAutoApprove(result.request_id), governance.PUSH_AUTO_APPROVE_MS + 1000);
|
|
7855
|
+
}
|
|
7856
|
+
} else {
|
|
7857
|
+
result = { error: `Unknown governance tool: ${name}` };
|
|
7858
|
+
}
|
|
6979
7859
|
break;
|
|
6980
7860
|
case 'get_work':
|
|
6981
7861
|
result = await toolGetWork(args || {});
|
|
@@ -7028,7 +7908,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7028
7908
|
|
|
7029
7909
|
// Global hook: on non-listen tools, check for pending messages and nudge with escalating urgency
|
|
7030
7910
|
// Enhanced nudge: includes sender names, addressed count, and message preview
|
|
7031
|
-
const listenTools = ['listen', '
|
|
7911
|
+
const listenTools = ['listen', 'wait_for_reply'];
|
|
7032
7912
|
if (registeredName && !listenTools.includes(name) && (isGroupMode() || isManagedMode())) {
|
|
7033
7913
|
try {
|
|
7034
7914
|
const pending = getUnconsumedMessages(registeredName);
|
|
@@ -7064,7 +7944,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7064
7944
|
result._nudge = `${pending.length} messages waiting${addressedHint}: ${senderSummary}. Latest: "${preview}...". Call listen_group().`;
|
|
7065
7945
|
}
|
|
7066
7946
|
}
|
|
7067
|
-
} catch {}
|
|
7947
|
+
} catch (e) { log.debug("nudge detection failed:", e.message); }
|
|
7068
7948
|
}
|
|
7069
7949
|
|
|
7070
7950
|
// Global hook: reputation tracking
|
|
@@ -7094,10 +7974,65 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
7094
7974
|
try { autoCompress(); } catch (e) { log.debug('auto-compress failed:', e.message); }
|
|
7095
7975
|
}
|
|
7096
7976
|
|
|
7977
|
+
// Coordinator mode hint: inject into every tool response for lead/manager/coordinator agents
|
|
7978
|
+
if (registeredName && typeof result === 'object' && result !== null) {
|
|
7979
|
+
try {
|
|
7980
|
+
const prof = getProfiles()[registeredName];
|
|
7981
|
+
const role = prof && prof.role ? prof.role.toLowerCase() : '';
|
|
7982
|
+
if (role === 'lead' || role === 'manager' || role === 'coordinator') {
|
|
7983
|
+
const coordMode = getConfig().coordinator_mode || 'responsive';
|
|
7984
|
+
result.coordinator_mode = coordMode;
|
|
7985
|
+
result.coordinator_hint = coordMode === 'responsive'
|
|
7986
|
+
? 'MODE: Stay with me — do NOT call listen(). Use consume_messages/workflow_status between human interactions.'
|
|
7987
|
+
: 'MODE: Run autonomously — use listen() to wait for agent results.';
|
|
7988
|
+
}
|
|
7989
|
+
} catch (e) { log.debug('coordinator mode hint failed:', e.message); }
|
|
7990
|
+
}
|
|
7991
|
+
|
|
7992
|
+
// Unread message hint: check if agent has pending messages on every tool call
|
|
7993
|
+
// This ensures agents see messages even when they forget to call listen()
|
|
7994
|
+
if (registeredName && typeof result === 'object' && result !== null && !listenTools.includes(name)) {
|
|
7995
|
+
try {
|
|
7996
|
+
const unread = getUnconsumedMessages(registeredName);
|
|
7997
|
+
if (unread.length > 0) {
|
|
7998
|
+
const latest = unread[unread.length - 1];
|
|
7999
|
+
result.unread_messages = unread.length;
|
|
8000
|
+
result.unread_preview = `${latest.from}: "${latest.content.substring(0, 100).replace(/\n/g, ' ')}"`;
|
|
8001
|
+
result.unread_action = `You have ${unread.length} unread message(s). Call listen() to receive them.`;
|
|
8002
|
+
}
|
|
8003
|
+
} catch (e) { log.debug('unread message hint failed:', e.message); }
|
|
8004
|
+
}
|
|
8005
|
+
|
|
8006
|
+
// Persistent listen() reminder — injected into EVERY non-listen tool response.
|
|
8007
|
+
// Keeps the instruction in the agent's immediate context even after extended
|
|
8008
|
+
// processing pushes earlier instructions out of working memory.
|
|
8009
|
+
if (registeredName && typeof result === 'object' && result !== null && !listenTools.includes(name)) {
|
|
8010
|
+
if (consecutiveNonListenCalls >= 3) {
|
|
8011
|
+
result._listen = `WARNING: You have NOT called listen() in ${consecutiveNonListenCalls} tool calls. Tools will be BLOCKED at 5. Call listen() NOW.`;
|
|
8012
|
+
} else {
|
|
8013
|
+
result._listen = 'After processing this result, call listen() to receive messages. Do NOT skip this.';
|
|
8014
|
+
}
|
|
8015
|
+
}
|
|
8016
|
+
|
|
8017
|
+
// Log successful tool call
|
|
8018
|
+
const duration = Date.now() - startTime;
|
|
8019
|
+
_audit.logToolCall(registeredName, name, args, result, duration, {
|
|
8020
|
+
session_id: `sess_${process.pid}`,
|
|
8021
|
+
branch: currentBranch || 'main'
|
|
8022
|
+
});
|
|
8023
|
+
|
|
7097
8024
|
return {
|
|
7098
8025
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
7099
8026
|
};
|
|
7100
8027
|
} catch (error) {
|
|
8028
|
+
// Log failed tool call
|
|
8029
|
+
const duration = Date.now() - startTime;
|
|
8030
|
+
const errorResult = { error: error.message };
|
|
8031
|
+
_audit.logToolCall(registeredName, name, args, errorResult, duration, {
|
|
8032
|
+
session_id: `sess_${process.pid}`,
|
|
8033
|
+
branch: currentBranch || 'main'
|
|
8034
|
+
});
|
|
8035
|
+
|
|
7101
8036
|
return {
|
|
7102
8037
|
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
7103
8038
|
isError: true,
|
|
@@ -7153,6 +8088,60 @@ process.on('exit', () => {
|
|
|
7153
8088
|
process.on('SIGTERM', () => process.exit(0));
|
|
7154
8089
|
process.on('SIGINT', () => process.exit(0));
|
|
7155
8090
|
|
|
8091
|
+
/**
|
|
8092
|
+
* Auto-reclaim a dead agent's identity on MCP process startup.
|
|
8093
|
+
* Scans agents.json for entries whose PID is dead, picks the most recently
|
|
8094
|
+
* active one, updates its PID to the current process, and restarts heartbeat.
|
|
8095
|
+
* Avoids the need for an explicit register() call on session reconnect.
|
|
8096
|
+
*/
|
|
8097
|
+
function autoReclaimDeadSeat() {
|
|
8098
|
+
try {
|
|
8099
|
+
if (!fs.existsSync(AGENTS_FILE)) return;
|
|
8100
|
+
const agents = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
|
|
8101
|
+
let bestName = null;
|
|
8102
|
+
let bestTime = 0;
|
|
8103
|
+
|
|
8104
|
+
for (const [name, entry] of Object.entries(agents)) {
|
|
8105
|
+
if (!entry || !entry.pid) continue;
|
|
8106
|
+
let alive = false;
|
|
8107
|
+
try { process.kill(entry.pid, 0); alive = true; } catch {}
|
|
8108
|
+
if (alive) continue;
|
|
8109
|
+
|
|
8110
|
+
const hbFile = heartbeatFile(name);
|
|
8111
|
+
let lastActivity = entry.last_activity;
|
|
8112
|
+
try {
|
|
8113
|
+
const hb = JSON.parse(fs.readFileSync(hbFile, 'utf8'));
|
|
8114
|
+
if (hb.last_activity) lastActivity = hb.last_activity;
|
|
8115
|
+
} catch {}
|
|
8116
|
+
|
|
8117
|
+
const ts = lastActivity ? new Date(lastActivity).getTime() : 0;
|
|
8118
|
+
if (ts > bestTime) {
|
|
8119
|
+
bestTime = ts;
|
|
8120
|
+
bestName = name;
|
|
8121
|
+
}
|
|
8122
|
+
}
|
|
8123
|
+
|
|
8124
|
+
if (!bestName) return;
|
|
8125
|
+
|
|
8126
|
+
const now = new Date().toISOString();
|
|
8127
|
+
agents[bestName].pid = process.pid;
|
|
8128
|
+
agents[bestName].ppid = process.ppid;
|
|
8129
|
+
agents[bestName].last_activity = now;
|
|
8130
|
+
saveAgents(agents);
|
|
8131
|
+
registeredName = bestName;
|
|
8132
|
+
autoReclaimedName = true; // mark as auto-reclaimed so toolRegister() can override it
|
|
8133
|
+
registeredToken = agents[bestName].token || '';
|
|
8134
|
+
touchHeartbeat(bestName);
|
|
8135
|
+
// Start 10s heartbeat interval so the agent stays alive past the first 30s window
|
|
8136
|
+
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
8137
|
+
heartbeatInterval = setInterval(() => { touchHeartbeat(registeredName); }, 10000);
|
|
8138
|
+
heartbeatInterval.unref();
|
|
8139
|
+
console.error(`[neohive] Auto-reclaimed seat "${bestName}" (previous PID dead)`);
|
|
8140
|
+
} catch (e) {
|
|
8141
|
+
console.error('[neohive] Auto-reclaim failed:', e.message);
|
|
8142
|
+
}
|
|
8143
|
+
}
|
|
8144
|
+
|
|
7156
8145
|
async function main() {
|
|
7157
8146
|
try {
|
|
7158
8147
|
ensureDataDir();
|
|
@@ -7161,14 +8150,154 @@ async function main() {
|
|
|
7161
8150
|
console.error('Fix: Run "npx neohive doctor" to diagnose the issue.');
|
|
7162
8151
|
process.exit(1);
|
|
7163
8152
|
}
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
8153
|
+
|
|
8154
|
+
// HTTP persistent server mode: --http flag or NEOHIVE_TRANSPORT=http
|
|
8155
|
+
const useHttp = process.argv.includes('--http') || process.env.NEOHIVE_TRANSPORT === 'http';
|
|
8156
|
+
|
|
8157
|
+
if (useHttp) {
|
|
8158
|
+
try {
|
|
8159
|
+
const http = require('http');
|
|
8160
|
+
const { randomUUID } = require('crypto');
|
|
8161
|
+
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
|
8162
|
+
const { isInitializeRequest } = require('@modelcontextprotocol/sdk/types.js');
|
|
8163
|
+
|
|
8164
|
+
const PORT = parseInt(process.env.NEOHIVE_SERVER_PORT || '4321', 10);
|
|
8165
|
+
const sessions = {};
|
|
8166
|
+
|
|
8167
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
8168
|
+
// CORS headers for local dev
|
|
8169
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
8170
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
8171
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
|
|
8172
|
+
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
|
|
8173
|
+
|
|
8174
|
+
if (req.method === 'OPTIONS') {
|
|
8175
|
+
res.writeHead(204);
|
|
8176
|
+
res.end();
|
|
8177
|
+
return;
|
|
8178
|
+
}
|
|
8179
|
+
|
|
8180
|
+
// Health check endpoint
|
|
8181
|
+
if (req.url === '/health') {
|
|
8182
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
8183
|
+
res.end(JSON.stringify({ status: 'ok', sessions: Object.keys(sessions).length }));
|
|
8184
|
+
return;
|
|
8185
|
+
}
|
|
8186
|
+
|
|
8187
|
+
if (req.url === '/mcp') {
|
|
8188
|
+
if (req.method === 'POST') {
|
|
8189
|
+
// Parse JSON body
|
|
8190
|
+
let body = '';
|
|
8191
|
+
for await (const chunk of req) body += chunk;
|
|
8192
|
+
let parsed;
|
|
8193
|
+
try { parsed = JSON.parse(body); } catch {
|
|
8194
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
8195
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null }));
|
|
8196
|
+
return;
|
|
8197
|
+
}
|
|
8198
|
+
|
|
8199
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
8200
|
+
|
|
8201
|
+
if (sessionId && sessions[sessionId]) {
|
|
8202
|
+
// Existing session — route to its transport
|
|
8203
|
+
await sessions[sessionId].transport.handleRequest(req, res, parsed);
|
|
8204
|
+
} else if (!sessionId && isInitializeRequest(parsed)) {
|
|
8205
|
+
// New session initialization
|
|
8206
|
+
const transport = new StreamableHTTPServerTransport({
|
|
8207
|
+
sessionIdGenerator: () => randomUUID(),
|
|
8208
|
+
onsessioninitialized: (sid) => {
|
|
8209
|
+
sessions[sid] = { transport, createdAt: Date.now() };
|
|
8210
|
+
console.error(`[HTTP] Session created: ${sid}`);
|
|
8211
|
+
},
|
|
8212
|
+
});
|
|
8213
|
+
|
|
8214
|
+
transport.onclose = () => {
|
|
8215
|
+
const sid = transport.sessionId;
|
|
8216
|
+
if (sid && sessions[sid]) {
|
|
8217
|
+
delete sessions[sid];
|
|
8218
|
+
console.error(`[HTTP] Session closed: ${sid}`);
|
|
8219
|
+
}
|
|
8220
|
+
};
|
|
8221
|
+
|
|
8222
|
+
await server.connect(transport);
|
|
8223
|
+
await transport.handleRequest(req, res, parsed);
|
|
8224
|
+
} else {
|
|
8225
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
8226
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: No valid session ID' }, id: null }));
|
|
8227
|
+
}
|
|
8228
|
+
} else if (req.method === 'GET') {
|
|
8229
|
+
// SSE stream for server-initiated notifications
|
|
8230
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
8231
|
+
if (sessionId && sessions[sessionId]) {
|
|
8232
|
+
await sessions[sessionId].transport.handleRequest(req, res);
|
|
8233
|
+
} else {
|
|
8234
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
8235
|
+
res.end(JSON.stringify({ error: 'Missing or invalid session ID' }));
|
|
8236
|
+
}
|
|
8237
|
+
} else if (req.method === 'DELETE') {
|
|
8238
|
+
// Session termination
|
|
8239
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
8240
|
+
if (sessionId && sessions[sessionId]) {
|
|
8241
|
+
await sessions[sessionId].transport.close();
|
|
8242
|
+
delete sessions[sessionId];
|
|
8243
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
8244
|
+
res.end(JSON.stringify({ success: true }));
|
|
8245
|
+
} else {
|
|
8246
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
8247
|
+
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
8248
|
+
}
|
|
8249
|
+
} else {
|
|
8250
|
+
res.writeHead(405, { Allow: 'GET, POST, DELETE' });
|
|
8251
|
+
res.end('Method Not Allowed');
|
|
8252
|
+
}
|
|
8253
|
+
} else {
|
|
8254
|
+
res.writeHead(404);
|
|
8255
|
+
res.end('Not Found');
|
|
8256
|
+
}
|
|
8257
|
+
});
|
|
8258
|
+
|
|
8259
|
+
httpServer.on('error', (err) => {
|
|
8260
|
+
if (err.code === 'EADDRINUSE') {
|
|
8261
|
+
console.error(`ERROR: Port ${PORT} is already in use.`);
|
|
8262
|
+
console.error(`Another neohive HTTP server may be running. Try:`);
|
|
8263
|
+
console.error(` kill $(lsof -ti :${PORT}) # free the port`);
|
|
8264
|
+
console.error(` NEOHIVE_SERVER_PORT=4322 npx neohive serve # use different port`);
|
|
8265
|
+
process.exit(1);
|
|
8266
|
+
}
|
|
8267
|
+
throw err;
|
|
8268
|
+
});
|
|
8269
|
+
|
|
8270
|
+
httpServer.listen(PORT, () => {
|
|
8271
|
+
console.error(`Neohive MCP server v6.0.0 running in HTTP mode on port ${PORT}`);
|
|
8272
|
+
console.error(`Endpoint: http://localhost:${PORT}/mcp`);
|
|
8273
|
+
console.error(`Health: http://localhost:${PORT}/health`);
|
|
8274
|
+
});
|
|
8275
|
+
|
|
8276
|
+
// Graceful shutdown
|
|
8277
|
+
process.on('SIGINT', () => {
|
|
8278
|
+
console.error('\n[HTTP] Shutting down...');
|
|
8279
|
+
for (const sid of Object.keys(sessions)) {
|
|
8280
|
+
try { sessions[sid].transport.close(); } catch {}
|
|
8281
|
+
}
|
|
8282
|
+
httpServer.close(() => process.exit(0));
|
|
8283
|
+
});
|
|
8284
|
+
} catch (e) {
|
|
8285
|
+
console.error('ERROR: HTTP server failed to start: ' + e.message);
|
|
8286
|
+
console.error('Fix: Ensure @modelcontextprotocol/sdk is up to date.');
|
|
8287
|
+
process.exit(1);
|
|
8288
|
+
}
|
|
8289
|
+
} else {
|
|
8290
|
+
// Default: stdio transport (one agent per process)
|
|
8291
|
+
try {
|
|
8292
|
+
autoReclaimDeadSeat();
|
|
8293
|
+
startStdinActivityTracker();
|
|
8294
|
+
const transport = new StdioServerTransport();
|
|
8295
|
+
await server.connect(transport);
|
|
8296
|
+
} catch (e) {
|
|
8297
|
+
console.error('ERROR: MCP server failed to start: ' + e.message);
|
|
8298
|
+
console.error('Fix: Run "npx neohive doctor" to check your setup.');
|
|
8299
|
+
process.exit(1);
|
|
8300
|
+
}
|
|
7172
8301
|
}
|
|
7173
8302
|
}
|
|
7174
8303
|
|