let-them-talk 4.0.2 → 4.3.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
@@ -41,6 +41,7 @@ let sendsSinceLastListen = 0; // enforced: must listen between sends in group mo
41
41
  let sendLimit = 1; // default: 1 send per listen cycle (2 if addressed)
42
42
  let unaddressedSends = 0; // response budget: unaddressed sends counter
43
43
  let budgetResetTime = Date.now(); // resets every 60s
44
+ let _channelSendTimes = {}; // per-channel rate limit sliding window
44
45
 
45
46
  // --- Read cache (eliminates 70%+ redundant disk I/O) ---
46
47
  const _cache = {};
@@ -181,6 +182,32 @@ function ensureDataDir() {
181
182
  }
182
183
  }
183
184
 
185
+ // Data version tracking — enables safe migrations between releases
186
+ const DATA_VERSION_FILE = path.join(DATA_DIR, '.version');
187
+ const CURRENT_DATA_VERSION = 1; // bump when data format changes require migration
188
+ let _migrationDone = false;
189
+
190
+ function migrateIfNeeded() {
191
+ if (_migrationDone) return;
192
+ _migrationDone = true;
193
+ ensureDataDir();
194
+ let dataVersion = 0;
195
+ try {
196
+ if (fs.existsSync(DATA_VERSION_FILE)) {
197
+ dataVersion = parseInt(fs.readFileSync(DATA_VERSION_FILE, 'utf8').trim()) || 0;
198
+ }
199
+ } catch {}
200
+ if (dataVersion >= CURRENT_DATA_VERSION) return;
201
+
202
+ // Run migrations in order
203
+ // v0 → v1: stamp initial version (no data changes needed, all fields are additive)
204
+ // Future migrations go here:
205
+ // if (dataVersion < 2) { /* migrate v1 → v2 */ }
206
+
207
+ // Stamp current version
208
+ try { fs.writeFileSync(DATA_VERSION_FILE, String(CURRENT_DATA_VERSION)); } catch {}
209
+ }
210
+
184
211
  const RESERVED_NAMES = ['__system__', '__all__', '__open__', '__close__', 'system'];
185
212
 
186
213
  function sanitizeName(name) {
@@ -423,7 +450,14 @@ function autoCompact() {
423
450
  const newContent = active.map(m => JSON.stringify(m)).join('\n') + (active.length ? '\n' : '');
424
451
  const tmpFile = msgFile + '.tmp';
425
452
  fs.writeFileSync(tmpFile, newContent);
426
- fs.renameSync(tmpFile, msgFile);
453
+ try {
454
+ fs.renameSync(tmpFile, msgFile);
455
+ } catch {
456
+ // Rename can fail on Windows if another process has the file open
457
+ // Clean up temp file and abort compaction — will retry next cycle
458
+ try { fs.unlinkSync(tmpFile); } catch {}
459
+ return;
460
+ }
427
461
  lastReadOffset = Buffer.byteLength(newContent, 'utf8');
428
462
 
429
463
  // Trim consumed ID files — keep only IDs still in active messages
@@ -589,20 +623,36 @@ function getHistoryFile(branch) {
589
623
 
590
624
  // --- Dynamic Guide (progressive disclosure) ---
591
625
 
592
- function buildGuide() {
626
+ function buildGuide(level = 'standard') {
593
627
  const agents = getAgents();
594
628
  const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
595
629
  const mode = getConfig().conversation_mode || 'direct';
596
630
  const channels = getChannelsData();
597
631
  const hasChannels = Object.keys(channels).length > 1; // more than just #general
598
- const hasTasks = getTasks().length > 0;
599
632
 
600
633
  const rules = [];
601
634
 
602
- // Tier 0 — THE one rule (always)
635
+ // Tier 0 — THE one rule (always included at every level)
603
636
  rules.push('AFTER EVERY ACTION, call listen_group(). This is how you receive messages. Never skip this.');
604
637
 
605
- // Tier 1core behavior (always)
638
+ // Minimal level: Tier 0 only for experienced agents refreshing rules
639
+ if (level === 'minimal') {
640
+ rules.push('Call get_briefing() when joining a project or after being away.');
641
+ rules.push('Lock files before editing shared code (lock_file / unlock_file).');
642
+ if (mode === 'group' || mode === 'managed') {
643
+ rules.push('Use reply_to when responding — you get faster cooldown (500ms vs default).');
644
+ rules.push('Messages not addressed to you show should_respond: false. Only respond if you have something new to add.');
645
+ }
646
+ return {
647
+ rules,
648
+ tier_info: `${rules.length} rules (minimal level, ${aliveCount} agents)`,
649
+ first_steps: mode === 'direct'
650
+ ? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait.'
651
+ : '1. Call get_briefing() for project context. 2. Call listen_group() to join. 3. Respond and listen_group() again.',
652
+ };
653
+ }
654
+
655
+ // Tier 1 — core behavior (standard + full)
606
656
  rules.push('Call get_briefing() when joining a project or after being away.');
607
657
  rules.push('Keep messages to 2-3 paragraphs max.');
608
658
  rules.push('When you finish work, report what you did and what files you changed.');
@@ -623,7 +673,7 @@ function buildGuide() {
623
673
 
624
674
  // Tier 3 — large teams (shown when 5+ agents)
625
675
  if (aliveCount >= 5) {
626
- rules.push('If listen_group returns idle: true, follow the work_suggestions. Do not sit idle.');
676
+ rules.push('listen_group blocks until messages arrive. Do not stop listening.');
627
677
  rules.push('Tasks auto-create channels (#task-xxx). Use them for focused discussion instead of #general.');
628
678
  rules.push('Use channels to split into sub-teams. Do not discuss everything in #general.');
629
679
  }
@@ -638,7 +688,7 @@ function buildGuide() {
638
688
  } catch {}
639
689
  }
640
690
 
641
- return {
691
+ const result = {
642
692
  rules,
643
693
  project_rules: projectRules.length > 0 ? projectRules : undefined,
644
694
  tier_info: `${rules.length} rules (${aliveCount} agents, ${mode} mode${hasChannels ? ', channels active' : ''})`,
@@ -646,7 +696,7 @@ function buildGuide() {
646
696
  ? '1. Call list_agents() to see who is online. 2. Send a message or call listen() to wait.'
647
697
  : '1. Call get_briefing() for project context. 2. Call listen_group() to join. 3. Respond and listen_group() again.',
648
698
  tool_categories: {
649
- 'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, handoff, share_file',
699
+ 'MESSAGING': 'send_message, broadcast, listen_group, listen, check_messages, get_history, get_summary, search_messages, handoff, share_file',
650
700
  'COORDINATION': 'get_briefing, log_decision, get_decisions, kb_write, kb_read, kb_list, call_vote, cast_vote, vote_status',
651
701
  'TASKS': 'create_task, update_task, list_tasks, declare_dependency, check_dependencies, suggest_task',
652
702
  'QUALITY': 'update_progress, get_progress, request_review, submit_review, get_reputation',
@@ -655,12 +705,31 @@ function buildGuide() {
655
705
  ...(mode === 'managed' ? { 'MANAGED MODE': 'claim_manager, yield_floor, set_phase' } : {}),
656
706
  },
657
707
  };
708
+
709
+ // Full level: add tool descriptions for complete reference
710
+ if (level === 'full') {
711
+ result.tool_details = {
712
+ 'listen_group': 'Blocks until messages arrive. Returns batch with priorities, context, agent statuses.',
713
+ 'send_message': 'Send to agent (to param). reply_to for threading. channel for sub-channels.',
714
+ 'lock_file / unlock_file': 'Exclusive file locking. Auto-releases on disconnect.',
715
+ 'log_decision': 'Persist decisions to prevent re-debating. Visible in get_briefing().',
716
+ 'create_task / update_task': 'Structured task management. Auto-creates channels at 5+ agents.',
717
+ 'kb_write / kb_read': 'Shared knowledge base. Any agent can read/write.',
718
+ 'suggest_task': 'AI-suggested next task based on your strengths and pending work.',
719
+ 'request_review / submit_review': 'Structured code review workflow with notifications.',
720
+ 'declare_dependency': 'Block a task until another completes. Auto-notifies on resolution.',
721
+ 'get_compressed_history': 'Summarized history for catching up without context overflow.',
722
+ };
723
+ }
724
+
725
+ return result;
658
726
  }
659
727
 
660
728
  // --- Tool implementations ---
661
729
 
662
730
  function toolRegister(name, provider = null) {
663
731
  ensureDataDir();
732
+ migrateIfNeeded(); // run data migrations on first register
664
733
  sanitizeName(name);
665
734
  lockAgentsFile();
666
735
 
@@ -698,13 +767,21 @@ function toolRegister(name, provider = null) {
698
767
  }
699
768
 
700
769
  // Start heartbeat — updates last_activity every 10s so dashboard knows we're alive
770
+ // Deterministic jitter per agent to spread writes across the interval (prevents lock storms at 10 agents)
771
+ const heartbeatJitter = name.split('').reduce((h, c) => h + c.charCodeAt(0), 0) % 2000;
701
772
  if (heartbeatInterval) clearInterval(heartbeatInterval);
702
773
  heartbeatInterval = setInterval(() => {
703
774
  try {
704
775
  const agents = getAgents();
705
776
  if (agents[registeredName]) {
706
- agents[registeredName].last_activity = new Date().toISOString();
707
- saveAgents(agents);
777
+ lockAgentsFile();
778
+ try {
779
+ const freshAgents = getAgents(); // re-read inside lock
780
+ if (freshAgents[registeredName]) {
781
+ freshAgents[registeredName].last_activity = new Date().toISOString();
782
+ saveAgents(freshAgents);
783
+ }
784
+ } finally { unlockAgentsFile(); }
708
785
  }
709
786
  // Managed mode: detect dead manager and dead turn holder
710
787
  if (isManagedMode()) {
@@ -738,11 +815,17 @@ function toolRegister(name, provider = null) {
738
815
  }
739
816
  }
740
817
  }
818
+ // Snapshot dead agents BEFORE cleanup (for auto-recovery)
819
+ snapshotDeadAgents(agents);
741
820
  // Clean up file locks held by dead agents
742
821
  cleanStaleLocks();
743
822
  cleanStaleChannelMembers();
823
+ // Auto-escalation: notify team about long-blocked tasks
824
+ escalateBlockedTasks();
825
+ // Stand-up meetings: periodic team check-ins
826
+ triggerStandupIfDue();
744
827
  } catch {}
745
- }, 10000);
828
+ }, 10000 + heartbeatJitter);
746
829
  heartbeatInterval.unref(); // Don't prevent process exit
747
830
 
748
831
  // Fire join event + recovery data for returning agents
@@ -772,6 +855,41 @@ function toolRegister(name, provider = null) {
772
855
  result.recovery.hint = 'You have prior context from a previous session. Call get_briefing() for a full project summary.';
773
856
  }
774
857
 
858
+ // Auto-recovery: load crash snapshot if it exists (TTL: 1 hour)
859
+ const recoveryFile = path.join(DATA_DIR, `recovery-${name}.json`);
860
+ if (fs.existsSync(recoveryFile)) {
861
+ try {
862
+ const snapshot = JSON.parse(fs.readFileSync(recoveryFile, 'utf8'));
863
+ const snapshotAge = Date.now() - new Date(snapshot.died_at).getTime();
864
+ if (snapshotAge > 3600000) {
865
+ // Stale snapshot (>1 hour) — discard
866
+ try { fs.unlinkSync(recoveryFile); } catch {}
867
+ } else {
868
+ if (!result.recovery) result.recovery = {};
869
+ result.recovery.previous_session = true;
870
+ result.recovery.died_at = snapshot.died_at;
871
+ result.recovery.crashed_ago = Math.round(snapshotAge / 1000) + 's';
872
+ if (snapshot.active_tasks && snapshot.active_tasks.length > 0) result.recovery.your_active_tasks = snapshot.active_tasks;
873
+ if (snapshot.locked_files && snapshot.locked_files.length > 0) {
874
+ result.recovery.locked_files_released = snapshot.locked_files;
875
+ result.recovery.lock_note = 'These files were locked by your previous session. Locks have been auto-released. Re-lock them with lock_file() before editing.';
876
+ }
877
+ if (snapshot.channels && snapshot.channels.length > 0) result.recovery.your_channels = snapshot.channels;
878
+ if (snapshot.last_messages_sent) result.recovery.last_messages_sent = snapshot.last_messages_sent;
879
+ // Agent memory fields
880
+ if (snapshot.decisions_made && snapshot.decisions_made.length > 0) result.recovery.decisions_made = snapshot.decisions_made;
881
+ if (snapshot.tasks_completed && snapshot.tasks_completed.length > 0) result.recovery.tasks_completed = snapshot.tasks_completed;
882
+ if (snapshot.kb_entries_written && snapshot.kb_entries_written.length > 0) result.recovery.kb_entries_written = snapshot.kb_entries_written;
883
+ if (snapshot.graceful) result.recovery.was_graceful = true;
884
+ result.recovery.hint = snapshot.graceful
885
+ ? 'You are RESUMING from a previous session that exited gracefully. Your memory (decisions, completed tasks, KB entries) is below. Continue where you left off.'
886
+ : 'You are RESUMING a previous session that crashed. Review your active tasks and locked files below, then continue where you left off. Do NOT restart work from scratch.';
887
+ // Clean up snapshot after loading
888
+ try { fs.unlinkSync(recoveryFile); } catch {}
889
+ }
890
+ } catch {}
891
+ }
892
+
775
893
  // Notify other agents
776
894
  fireEvent('agent_join', { agent: name });
777
895
 
@@ -782,14 +900,18 @@ function toolRegister(name, provider = null) {
782
900
  }
783
901
 
784
902
  // Update last_activity timestamp for this agent
903
+ // Uses file lock to prevent race with heartbeat writes
785
904
  function touchActivity() {
786
905
  if (!registeredName) return;
787
906
  try {
788
- const agents = getAgents();
789
- if (agents[registeredName]) {
790
- agents[registeredName].last_activity = new Date().toISOString();
791
- saveAgents(agents);
792
- }
907
+ lockAgentsFile();
908
+ try {
909
+ const agents = JSON.parse(fs.readFileSync(AGENTS_FILE, 'utf8'));
910
+ if (agents[registeredName]) {
911
+ agents[registeredName].last_activity = new Date().toISOString();
912
+ saveAgents(agents);
913
+ }
914
+ } finally { unlockAgentsFile(); }
793
915
  } catch {}
794
916
  }
795
917
 
@@ -834,6 +956,11 @@ function toolListAgents() {
834
956
  role: profile.role || '',
835
957
  bio: profile.bio || '',
836
958
  };
959
+ // Include workspace status if set (agent intent board)
960
+ try {
961
+ const ws = getWorkspace(name);
962
+ if (ws._status) result[name].current_status = ws._status;
963
+ } catch {}
837
964
  }
838
965
  return { agents: result };
839
966
  }
@@ -864,8 +991,24 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
864
991
  // Group mode cooldown — per-channel aware + split by addressing (fast/slow lane)
865
992
  let _cooldownApplied = 0;
866
993
  if (isGroupMode()) {
867
- // Per-channel cooldown: use channel member count, not total agents
994
+ // Per-channel rate limit: check if channel has custom rate_limit config
868
995
  const agentsNow = getAgents();
996
+ if (channel && channel !== 'general') {
997
+ const channels = getChannelsData();
998
+ const ch = channels[channel];
999
+ if (ch && ch.rate_limit && ch.rate_limit.max_sends_per_minute) {
1000
+ // Custom per-channel rate limit — check sliding window
1001
+ if (!_channelSendTimes[channel]) _channelSendTimes[channel] = [];
1002
+ const now = Date.now();
1003
+ _channelSendTimes[channel] = _channelSendTimes[channel].filter(t => now - t < 60000);
1004
+ if (_channelSendTimes[channel].length >= ch.rate_limit.max_sends_per_minute) {
1005
+ return { error: `Rate limit for #${channel}: max ${ch.rate_limit.max_sends_per_minute} messages/minute. Wait before sending.` };
1006
+ }
1007
+ _channelSendTimes[channel].push(now);
1008
+ }
1009
+ }
1010
+
1011
+ // Per-channel cooldown: use channel member count, not total agents
869
1012
  let memberCount;
870
1013
  if (channel && channel !== 'general') {
871
1014
  const channels = getChannelsData();
@@ -975,11 +1118,19 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
975
1118
  // Check if recipient is alive — warn if dead
976
1119
  const recipientAlive = isPidAlive(agents[to].pid, agents[to].last_activity);
977
1120
 
978
- // Resolve threading
1121
+ // Resolve threading — search main messages + channel files
979
1122
  let thread_id = null;
980
1123
  if (reply_to) {
981
- const allMsgs = readJsonl(getMessagesFile(currentBranch));
982
- const referencedMsg = allMsgs.find(m => m.id === reply_to);
1124
+ let referencedMsg = null;
1125
+ // Search channel file first if channel specified, then main messages
1126
+ if (channel && channel !== 'general') {
1127
+ const chMsgs = readJsonl(getChannelMessagesFile(channel));
1128
+ referencedMsg = chMsgs.find(m => m.id === reply_to);
1129
+ }
1130
+ if (!referencedMsg) {
1131
+ const allMsgs = readJsonl(getMessagesFile(currentBranch));
1132
+ referencedMsg = allMsgs.find(m => m.id === reply_to);
1133
+ }
983
1134
  if (referencedMsg) {
984
1135
  thread_id = referencedMsg.thread_id || referencedMsg.id;
985
1136
  } else {
@@ -1069,6 +1220,24 @@ async function toolSendMessage(content, to = null, reply_to = null, channel = nu
1069
1220
  if (isGroupMode() && !msg.addressed_to) { unaddressedSends++; }
1070
1221
 
1071
1222
  const result = { success: true, messageId: msg.id, from: msg.from, to: msg.to };
1223
+
1224
+ // Decision overlap hint: warn if message content overlaps with existing decisions
1225
+ if (isGroupMode()) {
1226
+ try {
1227
+ const decisions = readJsonFile(path.join(DATA_DIR, 'decisions.json')) || [];
1228
+ if (decisions.length > 0) {
1229
+ const contentLower = content.toLowerCase();
1230
+ const overlap = decisions.find(d => {
1231
+ const topic = (d.topic || '').toLowerCase();
1232
+ const decision = (d.decision || '').toLowerCase();
1233
+ return topic && contentLower.includes(topic) || decision.split(' ').filter(w => w.length > 4).some(w => contentLower.includes(w));
1234
+ });
1235
+ if (overlap) {
1236
+ result._decision_hint = `Related decision exists: "${overlap.decision}" (topic: ${overlap.topic || 'general'}). Check get_decisions() before re-debating.`;
1237
+ }
1238
+ }
1239
+ } catch {}
1240
+ }
1072
1241
  if (_cooldownApplied > 0) result.cooldown_applied_ms = _cooldownApplied;
1073
1242
  if (channel) result.channel = channel;
1074
1243
  if (currentBranch !== 'main') result.branch = currentBranch;
@@ -1639,8 +1808,6 @@ async function toolListenGroup() {
1639
1808
  setListening(true);
1640
1809
 
1641
1810
  const consumed = getConsumedIds(registeredName);
1642
- const idleThreshold = 60000; // 60s of no messages → return idle suggestions
1643
- const listenStarted = Date.now();
1644
1811
 
1645
1812
  // Poll indefinitely (in 5-min chunks to stay within any MCP limits, same as listen())
1646
1813
  while (true) {
@@ -1761,6 +1928,17 @@ async function toolListenGroup() {
1761
1928
  const recentSpeakers = new Set(history.slice(-10).map(m => m.from));
1762
1929
  const silent = agentNames.filter(n => !recentSpeakers.has(n) && n !== registeredName);
1763
1930
 
1931
+ // KB hints: check if batch messages mention KB topics
1932
+ let kbHints = [];
1933
+ try {
1934
+ const kb = getKB();
1935
+ const kbKeys = Object.keys(kb);
1936
+ if (kbKeys.length > 0) {
1937
+ const batchText = batch.map(m => m.content).join(' ').toLowerCase();
1938
+ kbHints = kbKeys.filter(k => batchText.includes(k.toLowerCase().replace(/[-_.]/g, ' '))).slice(0, 3);
1939
+ }
1940
+ } catch {}
1941
+
1764
1942
  const now = Date.now();
1765
1943
  const result = {
1766
1944
  messages: batch.map(m => {
@@ -1771,7 +1949,14 @@ async function toolListenGroup() {
1771
1949
  timestamp: m.timestamp,
1772
1950
  age_seconds: ageSec,
1773
1951
  ...(ageSec > 30 && { delayed: true }),
1774
- ...(m.reply_to && { reply_to: m.reply_to }),
1952
+ ...(m.reply_to && {
1953
+ reply_to: m.reply_to,
1954
+ // Thread context: include parent message preview so recipients have context
1955
+ _reply_context: (() => {
1956
+ const parent = history.find(h => h.id === m.reply_to);
1957
+ return parent ? `${parent.from}: "${parent.content.substring(0, 100)}..."` : null;
1958
+ })(),
1959
+ }),
1775
1960
  ...(m.thread_id && { thread_id: m.thread_id }),
1776
1961
  // addressed_to hint for group messages
1777
1962
  ...(m.addressed_to && { addressed_to: m.addressed_to }),
@@ -1836,47 +2021,15 @@ async function toolListenGroup() {
1836
2021
  }
1837
2022
  }
1838
2023
 
2024
+ if (kbHints.length > 0) {
2025
+ result.kb_hints = kbHints.map(k => `Relevant KB entry: "${k}" — call kb_read("${k}") for context`);
2026
+ }
1839
2027
  result.next_action = 'After processing these messages and sending your response, call listen_group() again immediately. Never stop listening.';
1840
2028
  return result;
1841
2029
  }
1842
2030
 
1843
- // Idle detection: after 60s of no messages, return with work suggestions
1844
- if (Date.now() - listenStarted > idleThreshold) {
1845
- setListening(false);
1846
- touchActivity();
1847
-
1848
- // Reset send counters (listening counts as a listen cycle)
1849
- sendsSinceLastListen = 0;
1850
- sendLimit = 1;
1851
-
1852
- const agents = getAgents();
1853
- const agentNames = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
1854
-
1855
- // Proactive work suggestions
1856
- const suggestion = toolSuggestTask();
1857
- const myTasks = getTasks().filter(t => t.assignee === registeredName && t.status === 'in_progress');
1858
- const pendingReviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== registeredName);
1859
- const unresolved = getDeps().filter(d => !d.resolved);
1860
-
1861
- const workItems = [];
1862
- if (myTasks.length > 0) workItems.push(`You have ${myTasks.length} task(s) in progress: ${myTasks.map(t => `"${t.title}" (${t.id})`).join(', ')}`);
1863
- if (pendingReviews.length > 0) workItems.push(`${pendingReviews.length} code review(s) waiting`);
1864
- if (unresolved.length > 0) workItems.push(`${unresolved.length} blocked dependency(s) to resolve`);
1865
-
1866
- return {
1867
- idle: true,
1868
- idle_seconds: Math.round((Date.now() - listenStarted) / 1000),
1869
- messages: [],
1870
- message_count: 0,
1871
- agents_online: agentNames.length,
1872
- work_suggestions: workItems.length > 0 ? workItems : ['No pending work. Ask the team what needs doing, or propose a new task.'],
1873
- suggestion: suggestion.suggestion !== 'none' ? suggestion : undefined,
1874
- instructions: myTasks.length > 0
1875
- ? `No new messages for ${Math.round((Date.now() - listenStarted) / 1000)}s. Continue working on your in-progress task(s), then call listen_group() again.`
1876
- : `No new messages for ${Math.round((Date.now() - listenStarted) / 1000)}s. ${suggestion.message || 'Ask the team what needs doing next.'}`,
1877
- next_action: 'Do some work (suggest_task, continue tasks, review code), then call listen_group() again.',
1878
- };
1879
- }
2031
+ // Note: NO idle timeout here. listen_group blocks indefinitely until messages arrive.
2032
+ // Work suggestions are delivered via enhanced nudge on other tool calls, not by breaking listen.
1880
2033
 
1881
2034
  await adaptiveSleep(0);
1882
2035
  }
@@ -2167,6 +2320,8 @@ function toolUpdateTask(taskId, status, notes = null) {
2167
2320
 
2168
2321
  task.status = status;
2169
2322
  task.updated_at = new Date().toISOString();
2323
+ // Clear escalation flag when task is unblocked
2324
+ if (status !== 'blocked' && task.escalated_at) delete task.escalated_at;
2170
2325
  if (notes) {
2171
2326
  task.notes.push({ by: registeredName, text: notes, at: new Date().toISOString() });
2172
2327
  }
@@ -2174,6 +2329,17 @@ function toolUpdateTask(taskId, status, notes = null) {
2174
2329
  saveTasks(tasks);
2175
2330
  touchActivity();
2176
2331
 
2332
+ // Auto-status: update agent's workspace status on task state changes
2333
+ try {
2334
+ if (status === 'in_progress') {
2335
+ saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `Working on: ${task.title}`, _status_since: new Date().toISOString() }));
2336
+ } else if (status === 'done') {
2337
+ saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `Completed: ${task.title}`, _status_since: new Date().toISOString() }));
2338
+ } else if (status === 'blocked') {
2339
+ saveWorkspace(registeredName, Object.assign(getWorkspace(registeredName), { _status: `BLOCKED on: ${task.title}`, _status_since: new Date().toISOString() }));
2340
+ }
2341
+ } catch {}
2342
+
2177
2343
  // Task-channel auto-join: when claiming a task that has a channel, auto-join it
2178
2344
  if (status === 'in_progress' && task.channel) {
2179
2345
  const channels = getChannelsData();
@@ -2207,6 +2373,13 @@ function toolUpdateTask(taskId, status, notes = null) {
2207
2373
  saveChannelsData(channels);
2208
2374
  }
2209
2375
  }
2376
+
2377
+ // Quality gate: auto-request review when task is completed
2378
+ const agents = getAgents();
2379
+ const aliveOthers = Object.keys(agents).filter(n => n !== registeredName && isPidAlive(agents[n].pid, agents[n].last_activity));
2380
+ if (aliveOthers.length > 0) {
2381
+ broadcastSystemMessage(`[REVIEW NEEDED] ${registeredName} completed task "${task.title}". Team: please review the work and call submit_review() if applicable.`, registeredName);
2382
+ }
2210
2383
  }
2211
2384
 
2212
2385
  return { success: true, task_id: task.id, status: task.status, title: task.title };
@@ -2261,6 +2434,45 @@ function toolGetSummary(lastN = 20) {
2261
2434
  };
2262
2435
  }
2263
2436
 
2437
+ function toolSearchMessages(query, from = null, limit = 20) {
2438
+ if (!registeredName) return { error: 'You must call register() first' };
2439
+ if (typeof query !== 'string' || query.length < 2) return { error: 'Query must be at least 2 characters' };
2440
+ if (query.length > 100) return { error: 'Query too long (max 100 chars)' };
2441
+ limit = Math.min(Math.max(1, limit || 20), 50);
2442
+
2443
+ // Search general history + all channel history files
2444
+ let allMessages = readJsonl(getHistoryFile(currentBranch));
2445
+ try {
2446
+ const myChannels = getAgentChannels(registeredName);
2447
+ for (const ch of myChannels) {
2448
+ if (ch === 'general') continue;
2449
+ const chFile = getChannelHistoryFile(ch);
2450
+ if (fs.existsSync(chFile)) {
2451
+ const chMsgs = readJsonl(chFile);
2452
+ allMessages = allMessages.concat(chMsgs);
2453
+ }
2454
+ }
2455
+ } catch {}
2456
+ // Sort by timestamp descending for newest-first results
2457
+ allMessages.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
2458
+
2459
+ const queryLower = query.toLowerCase();
2460
+ const results = [];
2461
+ for (let i = 0; i < allMessages.length && results.length < limit; i++) {
2462
+ const m = allMessages[i];
2463
+ if (from && m.from !== from) continue;
2464
+ if (m.content && m.content.toLowerCase().includes(queryLower)) {
2465
+ results.push({
2466
+ id: m.id, from: m.from, to: m.to,
2467
+ preview: m.content.substring(0, 200),
2468
+ timestamp: m.timestamp,
2469
+ ...(m.channel && { channel: m.channel }),
2470
+ });
2471
+ }
2472
+ }
2473
+ return { query, results_count: results.length, results, searched: allMessages.length };
2474
+ }
2475
+
2264
2476
  function toolReset() {
2265
2477
  if (!registeredName) {
2266
2478
  return { error: 'You must call register() first' };
@@ -2686,7 +2898,7 @@ function cleanStaleChannelMembers() {
2686
2898
  if (changed) saveChannelsData(channels);
2687
2899
  }
2688
2900
 
2689
- function toolJoinChannel(channelName, description) {
2901
+ function toolJoinChannel(channelName, description, rateLimit) {
2690
2902
  if (!registeredName) return { error: 'You must call register() first' };
2691
2903
  if (typeof channelName !== 'string' || channelName.length < 1 || channelName.length > 20) return { error: 'Channel name must be 1-20 chars' };
2692
2904
  sanitizeName(channelName);
@@ -2703,12 +2915,19 @@ function toolJoinChannel(channelName, description) {
2703
2915
  };
2704
2916
  } else if (!isChannelMember(channelName, registeredName)) {
2705
2917
  channels[channelName].members.push(registeredName);
2706
- } else {
2918
+ } else if (!rateLimit) {
2707
2919
  return { success: true, channel: channelName, message: 'Already a member of #' + channelName };
2708
2920
  }
2921
+ // Per-channel rate limit config — any member can set/update
2922
+ if (rateLimit && typeof rateLimit === 'object' && rateLimit.max_sends_per_minute) {
2923
+ const max = Math.min(Math.max(1, parseInt(rateLimit.max_sends_per_minute) || 10), 60);
2924
+ channels[channelName].rate_limit = { max_sends_per_minute: max };
2925
+ }
2709
2926
  saveChannelsData(channels);
2710
2927
  touchActivity();
2711
- return { success: true, channel: channelName, members: channels[channelName].members, message: 'Joined #' + channelName };
2928
+ const result = { success: true, channel: channelName, members: channels[channelName].members, message: 'Joined #' + channelName };
2929
+ if (channels[channelName].rate_limit) result.rate_limit = channels[channelName].rate_limit;
2930
+ return result;
2712
2931
  }
2713
2932
 
2714
2933
  function toolLeaveChannel(channelName) {
@@ -2747,6 +2966,113 @@ function toolListChannels() {
2747
2966
  return { channels: result, your_channels: getAgentChannels(registeredName) };
2748
2967
  }
2749
2968
 
2969
+ // Auto-escalation: notify team about tasks blocked for >5 minutes
2970
+ // Uses task.escalated_at field for cross-process dedup (file-based, not in-memory)
2971
+ function escalateBlockedTasks() {
2972
+ try {
2973
+ const tasks = getTasks();
2974
+ const now = Date.now();
2975
+ let changed = false;
2976
+ for (const task of tasks) {
2977
+ if (task.status !== 'blocked') continue;
2978
+ if (task.escalated_at) continue; // already escalated (cross-process safe)
2979
+ const blockedSince = new Date(task.updated_at).getTime();
2980
+ if (now - blockedSince > 300000) { // 5 minutes
2981
+ task.escalated_at = new Date().toISOString();
2982
+ changed = true;
2983
+ broadcastSystemMessage(
2984
+ `[ESCALATION] Task "${task.title}" (assigned to ${task.assignee || 'unassigned'}) has been blocked for ${Math.round((now - blockedSince) / 60000)} minutes. Team: can anyone help unblock it?`,
2985
+ registeredName
2986
+ );
2987
+ }
2988
+ }
2989
+ if (changed) saveTasks(tasks);
2990
+ } catch {}
2991
+ }
2992
+
2993
+ // Stand-up meetings: periodic team check-ins triggered by heartbeat
2994
+ let _lastStandupTime = 0;
2995
+ function triggerStandupIfDue() {
2996
+ try {
2997
+ const config = getConfig();
2998
+ const intervalHours = config.standup_interval_hours || 0; // 0 = disabled
2999
+ if (intervalHours <= 0) return;
3000
+ const intervalMs = intervalHours * 3600000;
3001
+ const now = Date.now();
3002
+
3003
+ // Only one process should trigger (the first to notice it's due)
3004
+ const standupFile = path.join(DATA_DIR, '.last-standup');
3005
+ let lastStandup = 0;
3006
+ if (fs.existsSync(standupFile)) {
3007
+ try { lastStandup = parseInt(fs.readFileSync(standupFile, 'utf8').trim()) || 0; } catch {}
3008
+ }
3009
+ if (now - lastStandup < intervalMs) return;
3010
+
3011
+ // Write timestamp first to prevent other processes from also triggering
3012
+ fs.writeFileSync(standupFile, String(now));
3013
+
3014
+ const agents = getAgents();
3015
+ const aliveAgents = Object.keys(agents).filter(n => isPidAlive(agents[n].pid, agents[n].last_activity));
3016
+ if (aliveAgents.length < 5) return; // stand-ups only for large teams (5+)
3017
+
3018
+ // Build standup context: tasks in progress, blocked, recently completed
3019
+ const tasks = getTasks();
3020
+ const inProgress = tasks.filter(t => t.status === 'in_progress');
3021
+ const blocked = tasks.filter(t => t.status === 'blocked');
3022
+ const recentDone = tasks.filter(t => t.status === 'done' && (now - new Date(t.updated_at).getTime()) < intervalMs);
3023
+
3024
+ let summary = `[STANDUP] Team check-in (${aliveAgents.length} agents online).`;
3025
+ if (inProgress.length > 0) summary += ` In progress: ${inProgress.map(t => `"${t.title}" (${t.assignee || '?'})`).join(', ')}.`;
3026
+ if (blocked.length > 0) summary += ` BLOCKED: ${blocked.map(t => `"${t.title}" (${t.assignee || '?'})`).join(', ')}.`;
3027
+ if (recentDone.length > 0) summary += ` Recently done: ${recentDone.length} task(s).`;
3028
+ summary += ' Each agent: report what you did, what\'s blocked, what\'s next. Then call listen_group().';
3029
+
3030
+ broadcastSystemMessage(summary, registeredName);
3031
+ } catch {}
3032
+ }
3033
+
3034
+ // Auto-recovery: snapshot dead agent state before cleanup
3035
+ // Creates recovery-{name}.json so replacement agent can resume
3036
+ function snapshotDeadAgents(agents) {
3037
+ for (const [name, info] of Object.entries(agents)) {
3038
+ if (name === registeredName) continue; // skip self
3039
+ if (isPidAlive(info.pid, info.last_activity)) continue; // skip alive
3040
+ const recoveryFile = path.join(DATA_DIR, `recovery-${name}.json`);
3041
+ if (fs.existsSync(recoveryFile)) continue; // already snapshotted
3042
+ try {
3043
+ const allTasks = getTasks();
3044
+ const tasks = allTasks.filter(t => t.assignee === name && (t.status === 'in_progress' || t.status === 'pending'));
3045
+ const locks = getLocks();
3046
+ const lockedFiles = Object.entries(locks).filter(([, l]) => l.agent === name).map(([f]) => f);
3047
+ const channels = getAgentChannels(name);
3048
+ const workspace = getWorkspace(name);
3049
+ const history = readJsonl(getHistoryFile(currentBranch));
3050
+ const lastSent = history.filter(m => m.from === name).slice(-5).map(m => ({ to: m.to, content: m.content.substring(0, 200), timestamp: m.timestamp }));
3051
+ // Agent memory: decisions made, tasks completed, KB keys written
3052
+ const decisions = readJsonFile(DECISIONS_FILE) || [];
3053
+ const myDecisions = decisions.filter(d => d.decided_by === name).slice(-10).map(d => ({ decision: d.decision, reasoning: (d.reasoning || '').substring(0, 150), decided_at: d.decided_at }));
3054
+ const completedTasks = allTasks.filter(t => t.assignee === name && t.status === 'done').slice(-10).map(t => ({ id: t.id, title: t.title }));
3055
+ const kb = readJsonFile(KB_FILE) || {};
3056
+ const kbKeysWritten = Object.keys(kb).filter(k => kb[k] && kb[k].updated_by === name);
3057
+ // Only snapshot if there's meaningful state to recover
3058
+ if (tasks.length > 0 || lockedFiles.length > 0 || Object.keys(workspace).length > 0 || myDecisions.length > 0 || completedTasks.length > 0) {
3059
+ writeJsonFile(recoveryFile, {
3060
+ agent: name,
3061
+ died_at: new Date().toISOString(),
3062
+ active_tasks: tasks.map(t => ({ id: t.id, title: t.title, status: t.status, description: (t.description || '').substring(0, 300) })),
3063
+ locked_files: lockedFiles,
3064
+ channels: channels.filter(c => c !== 'general'),
3065
+ workspace_keys: Object.keys(workspace),
3066
+ last_messages_sent: lastSent,
3067
+ decisions_made: myDecisions,
3068
+ tasks_completed: completedTasks,
3069
+ kb_entries_written: kbKeysWritten,
3070
+ });
3071
+ }
3072
+ } catch {}
3073
+ }
3074
+ }
3075
+
2750
3076
  // Auto-cleanup dead agent locks (called from heartbeat)
2751
3077
  function cleanStaleLocks() {
2752
3078
  const locks = getLocks();
@@ -2797,11 +3123,14 @@ function fireEvent(eventName, data) {
2797
3123
  }
2798
3124
  }
2799
3125
 
2800
- function toolGetGuide() {
3126
+ function toolGetGuide(level = 'standard') {
2801
3127
  if (!registeredName) return { error: 'You must call register() first' };
2802
- const guide = buildGuide();
3128
+ if (!['minimal', 'standard', 'full'].includes(level)) return { error: 'Level must be "minimal", "standard", or "full"' };
3129
+ const guide = buildGuide(level);
2803
3130
  guide.your_name = registeredName;
2804
- guide.workflow = '1. get_briefing → 2. list_tasks/suggest_task → 3. claim task → 4. lock_file → 5. work → 6. unlock_file → 7. update_task done → 8. listen_group';
3131
+ if (level !== 'minimal') {
3132
+ guide.workflow = '1. get_briefing → 2. list_tasks/suggest_task → 3. claim task → 4. lock_file → 5. work → 6. unlock_file → 7. update_task done → 8. listen_group';
3133
+ }
2805
3134
  return guide;
2806
3135
  }
2807
3136
 
@@ -3273,6 +3602,8 @@ function trackReputation(agent, action) {
3273
3602
  bugs_found: 0, messages_sent: 0, decisions_made: 0, votes_cast: 0,
3274
3603
  kb_contributions: 0, files_shared: 0, first_seen: new Date().toISOString(),
3275
3604
  last_active: new Date().toISOString(), strengths: [],
3605
+ task_times: [], // completion times in seconds for avg calculation
3606
+ response_times: [], // time between being addressed and responding
3276
3607
  };
3277
3608
  }
3278
3609
  const r = rep[agent];
@@ -3291,6 +3622,14 @@ function trackReputation(agent, action) {
3291
3622
  case 'bug_found': r.bugs_found++; break;
3292
3623
  }
3293
3624
 
3625
+ // Track task completion time if metadata provided
3626
+ if (action === 'task_complete' && arguments[2]) {
3627
+ const taskTime = arguments[2]; // seconds
3628
+ if (!r.task_times) r.task_times = [];
3629
+ r.task_times.push(taskTime);
3630
+ if (r.task_times.length > 50) r.task_times = r.task_times.slice(-50); // keep last 50
3631
+ }
3632
+
3294
3633
  // Auto-detect strengths based on stats
3295
3634
  r.strengths = [];
3296
3635
  if (r.tasks_completed >= 3) r.strengths.push('productive');
@@ -3312,14 +3651,20 @@ function toolGetReputation(agent) {
3312
3651
  }
3313
3652
 
3314
3653
  // All agents with ranking
3315
- const leaderboard = Object.entries(rep).map(([name, r]) => ({
3316
- agent: name,
3317
- score: r.tasks_completed * 10 + r.reviews_done * 5 + r.decisions_made * 3 + r.kb_contributions * 2 + r.bugs_found * 8,
3318
- tasks_completed: r.tasks_completed,
3319
- reviews_done: r.reviews_done,
3320
- strengths: r.strengths,
3321
- last_active: r.last_active,
3322
- })).sort((a, b) => b.score - a.score);
3654
+ const leaderboard = Object.entries(rep).map(([name, r]) => {
3655
+ const avgTaskTime = r.task_times && r.task_times.length > 0
3656
+ ? Math.round(r.task_times.reduce((a, b) => a + b, 0) / r.task_times.length) : null;
3657
+ return {
3658
+ agent: name,
3659
+ score: r.tasks_completed * 10 + r.reviews_done * 5 + r.decisions_made * 3 + r.kb_contributions * 2 + r.bugs_found * 8,
3660
+ tasks_completed: r.tasks_completed,
3661
+ reviews_done: r.reviews_done,
3662
+ strengths: r.strengths,
3663
+ avg_task_time_sec: avgTaskTime,
3664
+ messages_sent: r.messages_sent,
3665
+ last_active: r.last_active,
3666
+ };
3667
+ }).sort((a, b) => b.score - a.score);
3323
3668
 
3324
3669
  return { leaderboard, total_agents: leaderboard.length };
3325
3670
  }
@@ -3349,26 +3694,57 @@ function toolSuggestTask() {
3349
3694
  return { suggestion: 'none', message: 'No pending tasks, reviews, or blocked items. Ask the team what needs doing next.' };
3350
3695
  }
3351
3696
 
3697
+ // Check current workload — don't suggest new tasks if already overloaded
3698
+ const myActiveTasks = tasks.filter(t => t.assignee === registeredName && t.status === 'in_progress');
3699
+ if (myActiveTasks.length >= 3) {
3700
+ return { suggestion: 'finish_first', your_active_tasks: myActiveTasks.map(t => ({ id: t.id, title: t.title })), message: `You already have ${myActiveTasks.length} tasks in progress. Finish one before taking more.` };
3701
+ }
3702
+
3352
3703
  // Suggest based on reputation strengths
3353
- let suggested = pendingTasks[0] || unassignedTasks[0];
3354
3704
  if (myRep && myRep.strengths.includes('reviewer')) {
3355
3705
  const reviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== registeredName);
3356
3706
  if (reviews.length > 0) return { suggestion: 'review', review_id: reviews[0].id, file: reviews[0].file, message: `Based on your strengths (reviewer), review "${reviews[0].file}".` };
3357
3707
  }
3358
3708
 
3709
+ // Smart matching: score tasks by keyword overlap with agent's completed task history
3710
+ const myDoneTasks = tasks.filter(t => t.assignee === registeredName && t.status === 'done');
3711
+ const myKeywords = new Set();
3712
+ for (const t of myDoneTasks) {
3713
+ const words = (t.title + ' ' + (t.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
3714
+ words.forEach(w => myKeywords.add(w));
3715
+ }
3716
+
3717
+ let suggested = pendingTasks[0] || unassignedTasks[0];
3718
+ if (myKeywords.size > 0 && pendingTasks.length > 1) {
3719
+ // Score each pending task by keyword overlap
3720
+ let bestScore = 0;
3721
+ for (const task of pendingTasks) {
3722
+ const taskWords = (task.title + ' ' + (task.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
3723
+ const score = taskWords.filter(w => myKeywords.has(w)).length;
3724
+ if (score > bestScore) { bestScore = score; suggested = task; }
3725
+ }
3726
+ }
3727
+
3728
+ // Check for blocked tasks that might be unblockable
3729
+ const blockedTasks = tasks.filter(t => t.status === 'blocked');
3730
+ if (blockedTasks.length > 0 && pendingTasks.length === 0) {
3731
+ return { suggestion: 'unblock_task', task: { id: blockedTasks[0].id, title: blockedTasks[0].title }, message: `No pending tasks, but "${blockedTasks[0].title}" is blocked. Can you help unblock it?` };
3732
+ }
3733
+
3359
3734
  return {
3360
3735
  suggestion: 'task',
3361
3736
  task_id: suggested.id,
3362
3737
  title: suggested.title,
3363
3738
  description: suggested.description,
3364
3739
  message: `Suggested: "${suggested.title}". Call update_task("${suggested.id}", "in_progress") to claim it.`,
3740
+ ...(myKeywords.size > 0 && { match_reason: 'Based on your completed task history' }),
3365
3741
  };
3366
3742
  }
3367
3743
 
3368
3744
  // --- MCP Server setup ---
3369
3745
 
3370
3746
  const server = new Server(
3371
- { name: 'agent-bridge', version: '4.0.2' },
3747
+ { name: 'agent-bridge', version: '4.2.0' },
3372
3748
  { capabilities: { tools: {} } }
3373
3749
  );
3374
3750
 
@@ -3618,6 +3994,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3618
3994
  },
3619
3995
  },
3620
3996
  },
3997
+ {
3998
+ name: 'search_messages',
3999
+ description: 'Search conversation history by keyword. Returns matching messages with previews. Useful for finding past discussions, decisions, or code references.',
4000
+ inputSchema: {
4001
+ type: 'object',
4002
+ properties: {
4003
+ query: { type: 'string', description: 'Search term (min 2 chars)' },
4004
+ from: { type: 'string', description: 'Filter by sender agent name (optional)' },
4005
+ limit: { type: 'number', description: 'Max results (default: 20, max: 50)' },
4006
+ },
4007
+ required: ['query'],
4008
+ },
4009
+ },
3621
4010
  {
3622
4011
  name: 'reset',
3623
4012
  description: 'Clear all data files and start fresh. Automatically archives the conversation before clearing.',
@@ -3793,7 +4182,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3793
4182
  {
3794
4183
  name: 'join_channel',
3795
4184
  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.',
3796
- inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Channel name (1-30 chars, e.g. "backend", "testing")' }, description: { type: 'string', description: 'Channel description (optional, max 200 chars)' } }, required: ['name'] },
4185
+ 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'] },
3797
4186
  },
3798
4187
  {
3799
4188
  name: 'leave_channel',
@@ -3808,8 +4197,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
3808
4197
  // --- Briefing & Recovery ---
3809
4198
  {
3810
4199
  name: 'get_guide',
3811
- 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.',
3812
- inputSchema: { type: 'object', properties: {} },
4200
+ 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.',
4201
+ 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)' } } },
3813
4202
  },
3814
4203
  {
3815
4204
  name: 'get_briefing',
@@ -4011,6 +4400,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4011
4400
  case 'get_summary':
4012
4401
  result = toolGetSummary(args?.last_n);
4013
4402
  break;
4403
+ case 'search_messages':
4404
+ result = toolSearchMessages(args.query, args?.from, args?.limit);
4405
+ break;
4014
4406
  case 'reset':
4015
4407
  result = toolReset();
4016
4408
  break;
@@ -4051,7 +4443,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4051
4443
  result = await toolListenGroup();
4052
4444
  break;
4053
4445
  case 'join_channel':
4054
- result = toolJoinChannel(args.name, args?.description);
4446
+ result = toolJoinChannel(args.name, args?.description, args?.rate_limit);
4055
4447
  break;
4056
4448
  case 'leave_channel':
4057
4449
  result = toolLeaveChannel(args.name);
@@ -4060,7 +4452,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4060
4452
  result = toolListChannels();
4061
4453
  break;
4062
4454
  case 'get_guide':
4063
- result = toolGetGuide();
4455
+ result = toolGetGuide(args?.level);
4064
4456
  break;
4065
4457
  case 'get_briefing':
4066
4458
  result = toolGetBriefing();
@@ -4209,7 +4601,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4209
4601
  };
4210
4602
  if (repMap[name]) trackReputation(registeredName, repMap[name]);
4211
4603
  // Track task completion specifically
4212
- if (name === 'update_task' && args?.status === 'done') trackReputation(registeredName, 'task_complete');
4604
+ if (name === 'update_task' && args?.status === 'done') {
4605
+ // Calculate task completion time
4606
+ const tasks = getTasks();
4607
+ const doneTask = tasks.find(t => t.id === args.task_id);
4608
+ const taskTimeSec = doneTask ? Math.round((Date.now() - new Date(doneTask.created_at).getTime()) / 1000) : 0;
4609
+ trackReputation(registeredName, 'task_complete', taskTimeSec);
4610
+ }
4213
4611
  } catch {}
4214
4612
  }
4215
4613
 
@@ -4234,6 +4632,37 @@ process.on('exit', () => {
4234
4632
  unlockAgentsFile(); // Clean up any held lock
4235
4633
  unlockConfigFile();
4236
4634
  if (registeredName) {
4635
+ try {
4636
+ // Save final status to workspace before exit
4637
+ const ws = getWorkspace(registeredName);
4638
+ ws._status = 'Offline (graceful exit)';
4639
+ ws._status_since = new Date().toISOString();
4640
+ saveWorkspace(registeredName, ws);
4641
+ } catch {}
4642
+ try {
4643
+ // Agent memory: save recovery snapshot with decisions/tasks/KB on graceful exit
4644
+ const recoveryFile = path.join(DATA_DIR, `recovery-${registeredName}.json`);
4645
+ const allTasks = getTasks();
4646
+ const activeTasks = allTasks.filter(t => t.assignee === registeredName && (t.status === 'in_progress' || t.status === 'pending'));
4647
+ const completedTasks = allTasks.filter(t => t.assignee === registeredName && t.status === 'done').slice(-10).map(t => ({ id: t.id, title: t.title }));
4648
+ const decisions = readJsonFile(DECISIONS_FILE) || [];
4649
+ const myDecisions = decisions.filter(d => d.decided_by === registeredName).slice(-10).map(d => ({ decision: d.decision, reasoning: (d.reasoning || '').substring(0, 150), decided_at: d.decided_at }));
4650
+ const kb = readJsonFile(KB_FILE) || {};
4651
+ const kbKeysWritten = Object.keys(kb).filter(k => kb[k] && kb[k].updated_by === registeredName);
4652
+ const history = readJsonl(getHistoryFile(currentBranch));
4653
+ const lastSent = history.filter(m => m.from === registeredName).slice(-5).map(m => ({ to: m.to, content: m.content.substring(0, 200), timestamp: m.timestamp }));
4654
+ fs.writeFileSync(recoveryFile, JSON.stringify({
4655
+ agent: registeredName,
4656
+ died_at: new Date().toISOString(),
4657
+ graceful: true,
4658
+ active_tasks: activeTasks.map(t => ({ id: t.id, title: t.title, status: t.status, description: (t.description || '').substring(0, 300) })),
4659
+ channels: getAgentChannels(registeredName).filter(c => c !== 'general'),
4660
+ last_messages_sent: lastSent,
4661
+ decisions_made: myDecisions,
4662
+ tasks_completed: completedTasks,
4663
+ kb_entries_written: kbKeysWritten,
4664
+ }));
4665
+ } catch {}
4237
4666
  try {
4238
4667
  const agents = getAgents();
4239
4668
  if (agents[registeredName]) {
@@ -4250,7 +4679,7 @@ async function main() {
4250
4679
  ensureDataDir();
4251
4680
  const transport = new StdioServerTransport();
4252
4681
  await server.connect(transport);
4253
- console.error('Agent Bridge MCP server v3.10.1 running (56 tools)');
4682
+ console.error('Agent Bridge MCP server v4.1.0 running (57 tools)');
4254
4683
  }
4255
4684
 
4256
4685
  main().catch(console.error);