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/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
- // --- Structured logging ---
23
- const LOG_LEVEL = (process.env.NEOHIVE_LOG_LEVEL || 'warn').toLowerCase();
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
- // Data dir lives in the project where Claude Code runs, not where the package is installed
33
- const DATA_DIR = process.env.NEOHIVE_DATA_DIR || path.join(process.cwd(), '.neohive');
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
- // Plugins removed in v3.4.3 — unnecessary attack surface, CLIs have their own extension systems
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
- // --- Read cache (eliminates 70%+ redundant disk I/O) ---
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
- // File-based lock for config.json (prevents managed state race conditions)
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
- // Optimized: read only NEW lines from a JSONL file starting at byte offset
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
- // Scale fix: read only last N lines of a JSONL file (for history context)
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
- function touchHeartbeat(name) {
393
+ let _lastStdinActivity = null;
394
+
395
+ function touchHeartbeat(name, isListenCall = false) {
444
396
  if (!name) return;
445
397
  try {
446
- fs.writeFileSync(heartbeatFile(name), JSON.stringify({
447
- last_activity: new Date().toISOString(),
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
- } catch {}
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 < 5000) return cached.alive;
462
+ if (cached && Date.now() - cached.ts < SERVER_CONFIG.AGENT_CACHE_TTL_MS) return cached.alive;
470
463
 
471
- // Faster stale detection in autonomous mode (30s vs 60s) for quicker dead agent recovery
472
- const STALE_THRESHOLD = isAutonomousMode() ? 30000 : 60000;
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() - 10000;
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, only check ALIVE agents
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 aliveAgentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
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 ALIVE agents have consumed it (dead agents don't block)
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
- // __group__: check if all alive agents (except sender) have consumed
643
- return !aliveAgentNames.every(n => n === m.from || (perAgentConsumed[n] && perAgentConsumed[n].has(m.id)));
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
- lastReadOffset = Buffer.byteLength(newContent, 'utf8');
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), 30000);
909
- const bWords = cachedRead(bKey, () => ((b.title || '') + ' ' + (b.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3), 30000);
910
- const aScore = aWords.filter(w => historyKeywords.has(w)).length;
911
- const bScore = bWords.filter(w => historyKeywords.has(w)).length;
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() - 300000;
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 mode = getConfig().conversation_mode || 'direct';
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 dashboardRules = getRules().filter(r => r.active);
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 dashboardRules = getRules().filter(r => r.active);
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
- // If name was previously registered by a dead process, verify token to prevent impersonation
1315
- if (agents[name] && agents[name].token && !isPidAlive(agents[name].pid, agents[name].last_activity)) {
1316
- // Dead agent only allow re-registration from the same process (same token)
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
- unlockAgentsFile();
1325
- return { error: `Already registered as "${registeredName}". Cannot change name mid-session.`, current_name: registeredName };
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 = (agents[name] && agents[name].token) || generateToken();
1330
- agents[name] = { pid: process.pid, timestamp: now, last_activity: now, provider: provider || 'unknown', branch: currentBranch, token, started_at: now };
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
- registeredToken = token;
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
- // Auto-create profile if not exists
1336
- const profiles = getProfiles();
1337
- if (!profiles[name]) {
1338
- profiles[name] = { display_name: name, avatar: '', bio: '', role: '', created_at: now };
1339
- saveProfiles(profiles);
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
- // Start heartbeat updates last_activity every 10s so dashboard knows we're alive
1343
- // Deterministic jitter per agent to spread writes across the interval (prevents lock storms at 10 agents)
1344
- const heartbeatJitter = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0) % 2000;
1345
- if (heartbeatInterval) clearInterval(heartbeatInterval);
1346
- heartbeatInterval = setInterval(() => {
1347
- try {
1348
- // Scale fix: write per-agent heartbeat file instead of lock+read+write agents.json
1349
- // Eliminates write contention — each agent writes only its own file, no locking needed
1350
- touchHeartbeat(registeredName);
1351
- const agents = getAgents(); // cached + merges heartbeat files automatically
1352
- // Managed mode: detect dead manager and dead turn holder
1353
- if (isManagedMode()) {
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
- // Dead turn holder detection unstick the floor
1371
- if (!managedChanged && managed.turn_current && managed.turn_current !== registeredName && managed.manager) {
1372
- if (agents[managed.turn_current] && !isPidAlive(agents[managed.turn_current].pid, agents[managed.turn_current].last_activity)) {
1373
- const deadAgent = managed.turn_current;
1374
- managed.turn_current = null;
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
- // Snapshot dead agents BEFORE cleanup (for auto-recovery)
1385
- snapshotDeadAgents(agents);
1386
- // Clean up file locks held by dead agents
1387
- cleanStaleLocks();
1388
- cleanStaleChannelMembers();
1389
- // Auto-escalation: notify team about long-blocked tasks
1390
- escalateBlockedTasks();
1391
- // Stand-up meetings: periodic team check-ins
1392
- triggerStandupIfDue();
1393
- // Watchdog: nudge idle agents, reassign stuck work (autonomous mode only)
1394
- watchdogCheck();
1395
- } catch {}
1396
- }, 10000 + heartbeatJitter);
1397
- heartbeatInterval.unref(); // Don't prevent process exit
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: !alive ? 'dead' : idleSeconds > 60 ? 'sleeping' : 'active',
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
- if (isGroupMode() && sendsSinceLastListen >= effectiveSendLimit) {
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
- if (otherAgents.length === 0) {
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
- if (!agents[to]) {
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
- if (isGroupMode() && sendsSinceLastListen >= effectiveSendLimitBcast) {
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
- const _mfL1 = getMessagesFile(currentBranch);
2118
- if (fs.existsSync(_mfL1)) {
2119
- lastReadOffset = fs.statSync(_mfL1).size;
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 (msg.to !== registeredName || consumed.has(msg.id)) continue;
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 (msg.to !== registeredName || consumed.has(msg.id)) continue;
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() ? 30000 : null;
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: system > threaded replies > direct > broadcast
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
- if (m.system || m.from === '__system__') return 0;
2654
- if (m.reply_to || m.thread_id) return 1;
2655
- if (!m.broadcast) return 2;
2656
- return 3;
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 > 120000 ? 'unresponsive' : 'working';
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: !m.addressed_to || m.addressed_to.includes(agentName),
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 || 50), 500);
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
- status: 'pending', // all start pending; we'll activate ready ones below
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
- clearCheckpoint(registeredName, workflow_id, currentStep.id); // Item 8: clear checkpoint on completion
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
- function watchdogCheck() {
4061
- // Run in autonomous mode always, AND in group mode when agents are idle 5+ min
4062
- if (!isAutonomousMode() && !isGroupMode()) return;
4063
- if (!amIWatchdog()) return;
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 now = Date.now();
4067
- let agentsChanged = false;
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
- const idleTime = now - new Date(agent.last_activity).getTime();
4625
+ for (const wf of workflows) {
4626
+ if (wf.status !== 'active') continue;
4627
+ if (wf.paused) continue;
4074
4628
 
4075
- // IDLE > 2 minutes: nudge
4076
- if (idleTime > 120000 && !agent.watchdog_nudged) {
4077
- sendSystemMessage(name,
4078
- `[WATCHDOG] You've been idle for ${Math.round(idleTime / 60000)} minutes. Call get_work() to find your next task. Never be idle.`
4079
- );
4080
- trackReputation(name, 'watchdog_nudge');
4081
- agent.watchdog_nudged = now;
4082
- agentsChanged = true;
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
- // IDLE > 5 minutes: stronger nudge
4086
- if (idleTime > 300000 && !agent.watchdog_hard_nudged) {
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
- // Check for stuck workflow steps
4104
- const workflows = getWorkflows();
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
- // Helpers for new data files
4991
- function readJsonFile(file) { if (!fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
4992
- // File-to-cache-key map: writeJsonFile auto-invalidates the right cache entry
4993
- const _fileCacheKeys = {};
4994
- _fileCacheKeys[DECISIONS_FILE] = 'decisions';
4995
- _fileCacheKeys[KB_FILE] = 'kb';
4996
- _fileCacheKeys[LOCKS_FILE] = 'locks';
4997
- _fileCacheKeys[PROGRESS_FILE] = 'progress';
4998
- _fileCacheKeys[VOTES_FILE] = 'votes';
4999
- _fileCacheKeys[REVIEWS_FILE] = 'reviews';
5000
- _fileCacheKeys[DEPS_FILE] = 'deps';
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
- return { success: true, rule_id: rule.id, message: `Rule added: "${text.substring(0, 80)}". All agents will see this in their guide.` };
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.0.0' },
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 indefinitely. Auto-detects conversation mode: in group/managed mode, behaves like listen_group() (returns batched messages with agent statuses). In direct mode, returns one message at a time. Either listen() or listen_group() works in any mode — they auto-delegate to the correct behavior.',
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
- from: {
7356
+ mode: {
6197
7357
  type: 'string',
6198
- description: 'Only listen for messages from this specific agent (optional)',
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
- description: 'Only show messages from this specific agent (optional)',
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
- name: 'ack_message',
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: 'ID of the message to acknowledge',
7376
+ description: 'Optional: brief summary of what was done or why it was blocked (used as task notes)',
6238
7377
  },
6239
7378
  },
6240
- required: ['message_id'],
7379
+ additionalProperties: false,
6241
7380
  },
6242
7381
  },
7382
+ // --- Unified messages tool (consolidates check/consume/history/search/ack) ---
6243
7383
  {
6244
- name: 'get_history',
6245
- description: 'Get conversation history. Optionally filter by thread.',
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
- limit: {
6250
- type: 'number',
6251
- description: 'Number of recent messages to return (default: 50)',
6252
- },
6253
- thread_id: {
7389
+ action: {
6254
7390
  type: 'string',
6255
- description: 'Filter to only messages in this thread (optional)',
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: 'create_task',
6302
- description: 'Create a task and optionally assign it to another agent. Use for structured work delegation in multi-agent teams.',
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
- // --- Phase 1: Profiles ---
6372
- {
6373
- name: 'update_profile',
6374
- description: 'Update your agent profile (display name, avatar, bio, role). Profile data is shown in the dashboard.',
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
- name: 'listen_group',
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 'ack_message':
6831
- result = toolAckMessage(args.message_id);
6832
- break;
6833
- case 'get_history':
6834
- result = toolGetHistory(args?.limit, args?.thread_id);
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 = toolListTasks(args?.status, args?.assignee);
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
- result = toolGetSummary(args?.last_n);
6853
- break;
6854
- case 'search_messages':
6855
- result = toolSearchMessages(args.query, args?.from, args?.limit);
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 = toolWorkspaceList(args?.agent);
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 = toolWorkflowStatus(args?.workflow_id);
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 = toolListBranches();
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 = toolListChannels();
7805
+ result = channels.handlers[name](args || {});
6904
7806
  break;
6905
7807
  case 'get_guide':
6906
7808
  result = toolGetGuide(args?.level);
6907
7809
  break;
6908
- case 'get_briefing':
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
- result = toolUnlockFile(args?.file_path);
6916
- break;
6917
- case 'log_decision':
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
- result = toolSubmitReview(args.review_id, args.status, args?.feedback);
6952
- break;
6953
- case 'declare_dependency':
6954
- result = toolDeclareDependency(args.task_id, args.depends_on);
6955
- break;
6956
- case 'check_dependencies':
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 = toolGetReputation(args?.agent);
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 = toolSuggestTask();
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
- result = toolToggleRule(args.rule_id);
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', 'listen_group', 'listen_codex', 'wait_for_reply', 'check_messages'];
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
- try {
7165
- const transport = new StdioServerTransport();
7166
- await server.connect(transport);
7167
- console.error('Neohive MCP server v6.0.0 running (66 tools)');
7168
- } catch (e) {
7169
- console.error('ERROR: MCP server failed to start: ' + e.message);
7170
- console.error('Fix: Run "npx neohive doctor" to check your setup.');
7171
- process.exit(1);
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