@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 +3 -3
- package/state.js +13 -17
- package/tools/connect.js +9 -9
- package/tools/listen.js +148 -153
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tjamescouch/agentchat-mcp",
|
|
3
|
-
"version": "0.8.
|
|
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.
|
|
31
|
-
"zod": "^3.25.
|
|
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
|
-
//
|
|
16
|
-
|
|
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
|
-
*
|
|
90
|
+
* Increment the idle counter (called on nudge/timeout with no messages)
|
|
91
|
+
* Returns the new count.
|
|
92
92
|
*/
|
|
93
|
-
export function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
messageBuffer = messageBuffer.slice(-MAX_BUFFER_SIZE);
|
|
97
|
-
}
|
|
93
|
+
export function incrementIdleCount() {
|
|
94
|
+
consecutiveIdleCount++;
|
|
95
|
+
return consecutiveIdleCount;
|
|
98
96
|
}
|
|
99
97
|
|
|
100
98
|
/**
|
|
101
|
-
*
|
|
99
|
+
* Reset the idle counter (called when a real message arrives)
|
|
102
100
|
*/
|
|
103
|
-
export function
|
|
104
|
-
|
|
105
|
-
messageBuffer = [];
|
|
106
|
-
return messages;
|
|
101
|
+
export function resetIdleCount() {
|
|
102
|
+
consecutiveIdleCount = 0;
|
|
107
103
|
}
|
|
108
104
|
|
|
109
105
|
/**
|
|
110
|
-
*
|
|
106
|
+
* Get the current idle count
|
|
111
107
|
*/
|
|
112
|
-
export function
|
|
113
|
-
|
|
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,
|
|
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
|
|
136
|
+
// Reset last seen timestamp on new connection
|
|
136
137
|
resetLastSeen();
|
|
137
|
-
clearMessageBuffer();
|
|
138
138
|
|
|
139
|
-
// Persistent message handler -
|
|
140
|
-
//
|
|
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
|
|
144
|
-
if (msg.from === newClient.agentId || msg.from === '@server'
|
|
145
|
-
|
|
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
|
-
*
|
|
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,
|
|
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 =
|
|
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
|
-
//
|
|
39
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
91
|
-
const
|
|
92
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
});
|