@tjamescouch/agentchat-mcp 0.8.4 → 0.8.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tjamescouch/agentchat-mcp",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "MCP server for AgentChat - real-time AI agent communication",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,8 +27,8 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "^1.0.0",
30
- "@tjamescouch/agentchat": "^0.23.1",
31
- "zod": "^3.25.0"
30
+ "@tjamescouch/agentchat": "^0.22.2",
31
+ "zod": "^3.25.5"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "zod": "^3.25.0"
package/state.js CHANGED
@@ -12,9 +12,8 @@ export let keepaliveInterval = null;
12
12
  // Message tracking - timestamp of last message we returned to the caller
13
13
  export let lastSeenTimestamp = 0;
14
14
 
15
- // Message buffer - captures messages between listen() calls
16
- const MAX_BUFFER_SIZE = 200;
17
- let messageBuffer = [];
15
+ // Exponential backoff - tracks consecutive idle nudges
16
+ let consecutiveIdleCount = 0;
18
17
 
19
18
  // Default server
20
19
  export const DEFAULT_SERVER_URL = (() => {
@@ -88,27 +87,24 @@ export function getLastSeen() {
88
87
  }
89
88
 
90
89
  /**
91
- * Push a message into the buffer (called by persistent handler on client)
90
+ * Increment the idle counter (called on nudge/timeout with no messages)
91
+ * Returns the new count.
92
92
  */
93
- export function bufferMessage(msg) {
94
- messageBuffer.push(msg);
95
- if (messageBuffer.length > MAX_BUFFER_SIZE) {
96
- messageBuffer = messageBuffer.slice(-MAX_BUFFER_SIZE);
97
- }
93
+ export function incrementIdleCount() {
94
+ consecutiveIdleCount++;
95
+ return consecutiveIdleCount;
98
96
  }
99
97
 
100
98
  /**
101
- * Drain all buffered messages and clear the buffer
99
+ * Reset the idle counter (called when a real message arrives)
102
100
  */
103
- export function drainMessageBuffer() {
104
- const messages = messageBuffer;
105
- messageBuffer = [];
106
- return messages;
101
+ export function resetIdleCount() {
102
+ consecutiveIdleCount = 0;
107
103
  }
108
104
 
109
105
  /**
110
- * Clear the message buffer (e.g., on reconnect)
106
+ * Get the current idle count
111
107
  */
112
- export function clearMessageBuffer() {
113
- messageBuffer = [];
108
+ export function getIdleCount() {
109
+ return consecutiveIdleCount;
114
110
  }
package/tools/connect.js CHANGED
@@ -10,9 +10,10 @@ import path from 'path';
10
10
  import {
11
11
  client, keepaliveInterval,
12
12
  setClient, setServerUrl, setKeepaliveInterval,
13
- resetLastSeen, clearMessageBuffer, bufferMessage,
13
+ resetLastSeen,
14
14
  DEFAULT_SERVER_URL, KEEPALIVE_INTERVAL_MS
15
15
  } from '../state.js';
16
+ import { appendToInbox } from '../inbox-writer.js';
16
17
 
17
18
  /**
18
19
  * Base directory for identities
@@ -132,17 +133,16 @@ export function registerConnectTool(server) {
132
133
  setClient(newClient);
133
134
  setServerUrl(actualServerUrl);
134
135
 
135
- // Reset last seen timestamp and message buffer on new connection
136
+ // Reset last seen timestamp on new connection
136
137
  resetLastSeen();
137
- clearMessageBuffer();
138
138
 
139
- // Persistent message handler - buffers ALL messages between listen() calls
140
- // This is the fix for the listen() message drop bug: messages that arrive
141
- // while the agent is busy with other tools are captured here instead of lost.
139
+ // Persistent message handler - writes ALL messages to inbox.jsonl so
140
+ // listen always has a single source of truth (same file the daemon uses).
142
141
  newClient.on('message', (msg) => {
143
- // Skip own messages, server noise, and replays
144
- if (msg.from === newClient.agentId || msg.from === '@server' || msg.replay) return;
145
- bufferMessage({
142
+ // Skip own messages and server noise
143
+ if (msg.from === newClient.agentId || msg.from === '@server') return;
144
+ appendToInbox({
145
+ type: 'MSG',
146
146
  from: msg.from,
147
147
  to: msg.to,
148
148
  content: msg.content,
package/tools/listen.js CHANGED
@@ -1,18 +1,74 @@
1
1
  /**
2
2
  * AgentChat Listen Tool
3
- * Handles listening for messages with inbox checking for missed messages
3
+ * Listens for messages by polling inbox.jsonl the single source of truth
4
+ * for both daemon and direct-connection modes.
4
5
  */
5
6
 
6
7
  import { z } from 'zod';
7
8
  import fs from 'fs';
9
+ import path from 'path';
8
10
  import { getDaemonPaths } from '@tjamescouch/agentchat/lib/daemon.js';
9
11
  import { addJitter } from '@tjamescouch/agentchat/lib/jitter.js';
10
12
  import { ClientMessageType } from '@tjamescouch/agentchat/lib/protocol.js';
11
- import { client, getLastSeen, updateLastSeen, drainMessageBuffer } from '../state.js';
13
+ import { client, getLastSeen, updateLastSeen, getIdleCount, incrementIdleCount, resetIdleCount } from '../state.js';
12
14
 
13
15
  // Timeouts - agent cannot override these
14
16
  const ENFORCED_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour when alone
15
- const NUDGE_TIMEOUT_MS = 30 * 1000; // 30 seconds when others are present
17
+ const NUDGE_TIMEOUT_MS = 5 * 1000; // 5 seconds when others are present
18
+ const MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minute cap on backoff
19
+ const POLL_INTERVAL_MS = 500; // fallback poll interval
20
+
21
+ /**
22
+ * Read inbox.jsonl and return messages newer than lastSeen for the given channels.
23
+ */
24
+ function readInbox(paths, lastSeen, channels, agentId) {
25
+ if (!fs.existsSync(paths.inbox)) return [];
26
+
27
+ let content;
28
+ try {
29
+ content = fs.readFileSync(paths.inbox, 'utf-8');
30
+ } catch {
31
+ return [];
32
+ }
33
+
34
+ const lines = content.trim().split('\n').filter(Boolean);
35
+ const messages = [];
36
+
37
+ for (const line of lines) {
38
+ try {
39
+ const msg = JSON.parse(line);
40
+
41
+ if (msg.type !== 'MSG' || !msg.ts) continue;
42
+ if (msg.ts <= lastSeen) continue;
43
+ if (msg.from === agentId || msg.from === '@server') continue;
44
+
45
+ const isRelevantChannel = channels.includes(msg.to);
46
+ const isDMToUs = msg.to === agentId;
47
+ if (!isRelevantChannel && !isDMToUs) continue;
48
+
49
+ messages.push({
50
+ from: msg.from,
51
+ to: msg.to,
52
+ content: msg.content,
53
+ ts: msg.ts,
54
+ });
55
+ } catch {
56
+ // Skip invalid JSON lines
57
+ }
58
+ }
59
+
60
+ // Deduplicate by ts:from
61
+ const seen = new Set();
62
+ const deduped = messages.filter((m) => {
63
+ const key = `${m.ts}:${m.from}`;
64
+ if (seen.has(key)) return false;
65
+ seen.add(key);
66
+ return true;
67
+ });
68
+
69
+ deduped.sort((a, b) => a.ts - b.ts);
70
+ return deduped;
71
+ }
16
72
 
17
73
  /**
18
74
  * Register the listen tool with the MCP server
@@ -34,33 +90,14 @@ export function registerListenTool(server) {
34
90
  }
35
91
 
36
92
  const startTime = Date.now();
93
+ const paths = getDaemonPaths('default');
37
94
 
38
- // Collect replay messages during channel joins
39
- // The server sends buffered messages with { replay: true } on join,
40
- // so we must capture them before they're emitted and lost
41
- const replayMessages = [];
42
- const replayHandler = (msg) => {
43
- if (msg.replay && msg.from !== client.agentId && msg.from !== '@server') {
44
- replayMessages.push({
45
- from: msg.from,
46
- to: msg.to,
47
- content: msg.content,
48
- ts: msg.ts,
49
- });
50
- }
51
- };
52
- client.on('message', replayHandler);
53
-
54
- // Join channels (replay messages arrive here)
95
+ // Join/rejoin channels for presence (replays now go straight to inbox
96
+ // via the connect handler's message listener)
55
97
  for (const channel of channels) {
56
- if (!client.channels.has(channel)) {
57
- await client.join(channel);
58
- }
98
+ await client.join(channel);
59
99
  }
60
100
 
61
- // Done collecting replays
62
- client.removeListener('message', replayHandler);
63
-
64
101
  // Check channel occupancy to determine timeout behavior
65
102
  let othersPresent = false;
66
103
  let channelOccupancy = {};
@@ -79,7 +116,6 @@ export function registerListenTool(server) {
79
116
  }
80
117
  }
81
118
  }
82
- const lastSeen = getLastSeen();
83
119
 
84
120
  // Set presence to 'listening' so other agents see we're active
85
121
  const setPresence = (status) => {
@@ -87,156 +123,115 @@ export function registerListenTool(server) {
87
123
  };
88
124
  setPresence('listening');
89
125
 
90
- // Drain the persistent message buffer (messages captured between listen calls)
91
- const buffered = drainMessageBuffer().filter(m => {
92
- // Filter to relevant channels/DMs, skip already-seen messages
93
- const isRelevant = channels.includes(m.to) || m.to === client.agentId;
94
- return isRelevant && (!m.ts || m.ts > lastSeen);
95
- });
96
-
97
- // Start with buffered messages + replay messages captured during join
98
- const paths = getDaemonPaths('default');
99
- let missedMessages = [...buffered, ...replayMessages];
100
-
101
- if (fs.existsSync(paths.inbox)) {
102
- try {
103
- const content = fs.readFileSync(paths.inbox, 'utf-8');
104
- const lines = content.trim().split('\n').filter(Boolean);
105
-
106
- for (const line of lines) {
107
- try {
108
- const msg = JSON.parse(line);
109
-
110
- // Skip if not a message type or missing timestamp
111
- if (msg.type !== 'MSG' || !msg.ts) continue;
112
-
113
- // Skip messages we've already seen
114
- if (msg.ts <= lastSeen) continue;
115
-
116
- // Skip own messages and server messages
117
- if (msg.from === client.agentId || msg.from === '@server') continue;
118
-
119
- // Only include messages for our channels (including DMs)
120
- const isRelevantChannel = channels.includes(msg.to);
121
- const isDMToUs = msg.to === client.agentId;
122
- if (!isRelevantChannel && !isDMToUs) continue;
123
-
124
- missedMessages.push({
125
- from: msg.from,
126
- to: msg.to,
127
- content: msg.content,
128
- ts: msg.ts,
129
- });
130
- } catch {
131
- // Skip invalid JSON lines
132
- }
133
- }
134
-
135
- // Sort by timestamp ascending (oldest first)
136
- missedMessages.sort((a, b) => a.ts - b.ts);
137
- } catch {
138
- // Inbox read error, continue to blocking listen
139
- }
140
- }
126
+ // --- First check: return immediately if inbox has unseen messages ---
127
+ const lastSeen = getLastSeen();
128
+ const immediate = readInbox(paths, lastSeen, channels, client.agentId);
141
129
 
142
- // If we have missed messages (from replay or inbox), return them immediately
143
- if (missedMessages.length > 0) {
144
- // Deduplicate by timestamp + from (replay and inbox may overlap)
145
- const seen = new Set();
146
- missedMessages = missedMessages.filter((m) => {
147
- const key = `${m.ts}:${m.from}`;
148
- if (seen.has(key)) return false;
149
- seen.add(key);
150
- return true;
151
- });
152
-
153
- // Sort by timestamp ascending (oldest first)
154
- missedMessages.sort((a, b) => a.ts - b.ts);
155
-
156
- // Update last seen to the newest message timestamp
157
- const newestTs = missedMessages[missedMessages.length - 1].ts;
130
+ if (immediate.length > 0) {
131
+ const newestTs = immediate[immediate.length - 1].ts;
158
132
  updateLastSeen(newestTs);
159
-
133
+ resetIdleCount();
160
134
  setPresence('online');
161
135
  return {
162
- content: [
163
- {
164
- type: 'text',
165
- text: JSON.stringify({
166
- messages: missedMessages,
167
- from_inbox: replayMessages.length === 0,
168
- from_replay: replayMessages.length > 0,
169
- elapsed_ms: Date.now() - startTime,
170
- }),
171
- },
172
- ],
136
+ content: [{
137
+ type: 'text',
138
+ text: JSON.stringify({
139
+ messages: immediate,
140
+ from_inbox: true,
141
+ elapsed_ms: Date.now() - startTime,
142
+ }),
143
+ }],
173
144
  };
174
145
  }
175
146
 
176
- // No missed messages, wait for new ones
147
+ // --- No unseen messages: poll newdata semaphore until timeout ---
177
148
  return new Promise((resolve) => {
149
+ let watcher = null;
150
+ let pollId = null;
178
151
  let timeoutId = null;
179
152
 
180
- const messageHandler = (msg) => {
181
- // Filter out own messages, replays, and server messages
182
- if (msg.from === client.agentId || msg.replay || msg.from === '@server') {
183
- return;
184
- }
185
-
186
- // Update last seen timestamp
187
- if (msg.ts) {
188
- updateLastSeen(msg.ts);
189
- }
153
+ const cleanup = () => {
154
+ if (watcher) { watcher.close(); watcher = null; }
155
+ if (pollId) { clearInterval(pollId); pollId = null; }
156
+ if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
157
+ setPresence('online');
158
+ };
190
159
 
191
- // Got a real message - return immediately
192
- cleanup();
193
- resolve({
194
- content: [
195
- {
160
+ const tryRead = () => {
161
+ const msgs = readInbox(paths, getLastSeen(), channels, client.agentId);
162
+ if (msgs.length > 0) {
163
+ const newestTs = msgs[msgs.length - 1].ts;
164
+ updateLastSeen(newestTs);
165
+ resetIdleCount();
166
+ cleanup();
167
+ resolve({
168
+ content: [{
196
169
  type: 'text',
197
170
  text: JSON.stringify({
198
- messages: [{
199
- from: msg.from,
200
- to: msg.to,
201
- content: msg.content,
202
- ts: msg.ts,
203
- }],
204
- from_inbox: false,
171
+ messages: msgs,
172
+ from_inbox: true,
205
173
  elapsed_ms: Date.now() - startTime,
206
174
  }),
207
- },
208
- ],
209
- });
175
+ }],
176
+ });
177
+ return true;
178
+ }
179
+ return false;
210
180
  };
211
181
 
212
- const cleanup = () => {
213
- client.removeListener('message', messageHandler);
214
- if (timeoutId) clearTimeout(timeoutId);
215
- setPresence('online');
216
- };
182
+ // Watch the newdata semaphore file for changes
183
+ const newdataDir = path.dirname(paths.newdata);
184
+ const newdataFile = path.basename(paths.newdata);
217
185
 
218
- client.on('message', messageHandler);
186
+ // Ensure directory exists so fs.watch doesn't throw
187
+ if (!fs.existsSync(newdataDir)) {
188
+ fs.mkdirSync(newdataDir, { recursive: true });
189
+ }
219
190
 
220
- // Use shorter timeout when others are present to break deadlock
221
- const baseTimeout = othersPresent ? NUDGE_TIMEOUT_MS : ENFORCED_TIMEOUT_MS;
191
+ try {
192
+ watcher = fs.watch(newdataDir, (eventType, filename) => {
193
+ if (filename === newdataFile) {
194
+ tryRead();
195
+ }
196
+ });
197
+ watcher.on('error', () => {
198
+ // Watcher died — fallback poll will still work
199
+ if (watcher) { watcher.close(); watcher = null; }
200
+ });
201
+ } catch {
202
+ // fs.watch not available on this platform — rely on poll
203
+ }
204
+
205
+ // Fallback poll in case fs.watch misses events
206
+ pollId = setInterval(() => {
207
+ tryRead();
208
+ }, POLL_INTERVAL_MS);
209
+
210
+ // Exponential backoff timeout
211
+ const idleCount = getIdleCount();
212
+ const backoffMultiplier = Math.pow(2, idleCount);
213
+ const baseTimeout = othersPresent
214
+ ? Math.min(NUDGE_TIMEOUT_MS * backoffMultiplier, MAX_BACKOFF_MS)
215
+ : ENFORCED_TIMEOUT_MS;
222
216
  const actualTimeout = addJitter(baseTimeout, 0.2);
223
217
 
224
218
  timeoutId = setTimeout(() => {
219
+ incrementIdleCount();
225
220
  cleanup();
226
221
  resolve({
227
- content: [
228
- {
229
- type: 'text',
230
- text: JSON.stringify({
231
- messages: [],
232
- timeout: !othersPresent,
233
- nudge: othersPresent,
234
- others_waiting: othersPresent,
235
- channel_occupancy: channelOccupancy,
236
- elapsed_ms: Date.now() - startTime,
237
- }),
238
- },
239
- ],
222
+ content: [{
223
+ type: 'text',
224
+ text: JSON.stringify({
225
+ messages: [],
226
+ timeout: !othersPresent,
227
+ nudge: othersPresent,
228
+ others_waiting: othersPresent,
229
+ channel_occupancy: channelOccupancy,
230
+ idle_count: idleCount + 1,
231
+ next_timeout_ms: Math.min(NUDGE_TIMEOUT_MS * Math.pow(2, idleCount + 1), MAX_BACKOFF_MS),
232
+ elapsed_ms: Date.now() - startTime,
233
+ }),
234
+ }],
240
235
  });
241
236
  }, actualTimeout);
242
237
  });