@grknbyk/agent-wire 0.14.1 → 0.15.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/README.md +522 -517
- package/bin/agent-wire.mjs +5 -1
- package/package.json +41 -41
- package/src/config.mjs +6 -0
- package/src/mcp.mjs +21 -14
- package/src/slack.mjs +46 -13
package/bin/agent-wire.mjs
CHANGED
|
@@ -54,7 +54,7 @@ async function drain() {
|
|
|
54
54
|
const { pollOnce } = await import('../src/mcp.mjs');
|
|
55
55
|
const { refreshLatest, updateNotice } = await import('../src/version.mjs');
|
|
56
56
|
await Promise.all([
|
|
57
|
-
pollOnce(config).catch(() => {
|
|
57
|
+
pollOnce(config, { budgetMs: DRAIN_POLL_BUDGET_MS }).catch(() => {
|
|
58
58
|
// Offline is not an error here; the next drain catches up.
|
|
59
59
|
}),
|
|
60
60
|
refreshLatest(),
|
|
@@ -141,6 +141,10 @@ const showStatus = async () => (await import('../src/status.mjs')).runStatus();
|
|
|
141
141
|
// `drain` runs on every prompt and never needs it.
|
|
142
142
|
const PACKAGE_NAME = '@grknbyk/agent-wire';
|
|
143
143
|
|
|
144
|
+
// A prompt hook that runs long enough to be killed costs the person their whole
|
|
145
|
+
// prompt, so this is the one place where a partial answer is the right answer.
|
|
146
|
+
const DRAIN_POLL_BUDGET_MS = 6000;
|
|
147
|
+
|
|
144
148
|
const packageJson = async () => {
|
|
145
149
|
const { readFileSync } = await import('node:fs');
|
|
146
150
|
const { fileURLToPath } = await import('node:url');
|
package/package.json
CHANGED
|
@@ -1,41 +1,41 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@grknbyk/agent-wire",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Let AI coding agents message each other through a shared Slack channel, over MCP.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "Gürkan Bıyık <gurkan.biyik@outlook.com>",
|
|
8
|
-
"homepage": "https://github.com/grknbyk/agent-wire",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/grknbyk/agent-wire.git"
|
|
12
|
-
},
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/grknbyk/agent-wire/issues"
|
|
15
|
-
},
|
|
16
|
-
"bin": {
|
|
17
|
-
"agent-wire": "bin/agent-wire.mjs"
|
|
18
|
-
},
|
|
19
|
-
"engines": {
|
|
20
|
-
"node": ">=20"
|
|
21
|
-
},
|
|
22
|
-
"scripts": {
|
|
23
|
-
"test": "node --test \"test/*.test.mjs\"",
|
|
24
|
-
"bench": "node bench/bench.mjs"
|
|
25
|
-
},
|
|
26
|
-
"files": [
|
|
27
|
-
"bin",
|
|
28
|
-
"src",
|
|
29
|
-
"assets",
|
|
30
|
-
"manifest.json"
|
|
31
|
-
],
|
|
32
|
-
"keywords": [
|
|
33
|
-
"mcp",
|
|
34
|
-
"model-context-protocol",
|
|
35
|
-
"slack",
|
|
36
|
-
"ai-agents",
|
|
37
|
-
"agent-communication",
|
|
38
|
-
"claude-code",
|
|
39
|
-
"cursor"
|
|
40
|
-
]
|
|
41
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@grknbyk/agent-wire",
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"description": "Let AI coding agents message each other through a shared Slack channel, over MCP.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Gürkan Bıyık <gurkan.biyik@outlook.com>",
|
|
8
|
+
"homepage": "https://github.com/grknbyk/agent-wire",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/grknbyk/agent-wire.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/grknbyk/agent-wire/issues"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"agent-wire": "bin/agent-wire.mjs"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
24
|
+
"bench": "node bench/bench.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"src",
|
|
29
|
+
"assets",
|
|
30
|
+
"manifest.json"
|
|
31
|
+
],
|
|
32
|
+
"keywords": [
|
|
33
|
+
"mcp",
|
|
34
|
+
"model-context-protocol",
|
|
35
|
+
"slack",
|
|
36
|
+
"ai-agents",
|
|
37
|
+
"agent-communication",
|
|
38
|
+
"claude-code",
|
|
39
|
+
"cursor"
|
|
40
|
+
]
|
|
41
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -222,7 +222,13 @@ function prunedScopes(scopes) {
|
|
|
222
222
|
return Object.fromEntries(keys.filter((key) => !doomed.has(key)).map((key) => [key, scopes[key]]));
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
// Naming no channel is an answer only while there is one channel to mean. Past
|
|
226
|
+
// that it is a call that forgot the argument, and picking the first one sends
|
|
227
|
+
// somebody's work to the wrong room without a word.
|
|
228
|
+
export const isAmbiguous = (config, wanted) => !wanted && (config?.channels?.length ?? 0) > 1;
|
|
229
|
+
|
|
225
230
|
export function findChannel(config, wanted) {
|
|
231
|
+
if (isAmbiguous(config, wanted)) return null;
|
|
226
232
|
if (!wanted) return defaultChannel(config);
|
|
227
233
|
const name = String(wanted).replace(/^#/, '').toLowerCase();
|
|
228
234
|
return config.channels?.find((channel) => channel.name.toLowerCase() === name || channel.id === wanted) ?? null;
|
package/src/mcp.mjs
CHANGED
|
@@ -7,9 +7,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
8
|
import { dirname, join } from 'node:path';
|
|
9
9
|
|
|
10
|
-
import { MODES, activeChannels, channelMode, findChannel, loadConfig, paths, pollableChannels, readJson, scopeId } from './config.mjs';
|
|
10
|
+
import { MODES, activeChannels, channelMode, findChannel, isAmbiguous, loadConfig, paths, pollableChannels, readJson, scopeId } from './config.mjs';
|
|
11
11
|
import { DEFAULT_COUNT, appendMessages, archive, findByRef, findByTs, markRead, readCursor, selectMessages, writeCursor } from './inbox.mjs';
|
|
12
|
-
import { FINGERPRINT_CHARS, listPeers, signMessage } from './identity.mjs';
|
|
12
|
+
import { FINGERPRINT_CHARS, listPeers, signMessage } from './identity.mjs';
|
|
13
13
|
import { refusalFor } from './manners.mjs';
|
|
14
14
|
import { CHANNEL_CONCURRENCY, listMembers, mapLimit, pollChannel, postMessage, slackClient, uploadFile } from './slack.mjs';
|
|
15
15
|
import { MAX_HOPS, TEXT_MAX, formatMessage, mintNonce, mintRef, renderEnvelope } from './protocol.mjs';
|
|
@@ -109,7 +109,7 @@ const TOOLS = [
|
|
|
109
109
|
description: 'Everyone in one channel, agents and humans alike. Only channels the bot was invited to can be asked about; there is no way to list the workspace.',
|
|
110
110
|
inputSchema: {
|
|
111
111
|
type: 'object',
|
|
112
|
-
properties: { channel: { type: 'string', description: 'channel name
|
|
112
|
+
properties: { channel: { type: 'string', description: 'channel name. Omit it only while one channel is configured; past that, omitting it is refused rather than guessed' } },
|
|
113
113
|
},
|
|
114
114
|
},
|
|
115
115
|
{
|
|
@@ -133,7 +133,7 @@ const TOOLS = [
|
|
|
133
133
|
properties: {
|
|
134
134
|
to: { type: 'string', description: 'recipient nickname, or "all"' },
|
|
135
135
|
text: { type: 'string' },
|
|
136
|
-
channel: { type: 'string', description: 'channel name
|
|
136
|
+
channel: { type: 'string', description: 'channel name. Omit it only while one channel is configured; past that, omitting it is refused rather than guessed' },
|
|
137
137
|
reply_to: { type: 'string', description: 'the ts of the message being answered, as shown by inbox' },
|
|
138
138
|
},
|
|
139
139
|
required: ['to', 'text'],
|
|
@@ -148,7 +148,7 @@ const TOOLS = [
|
|
|
148
148
|
to: { type: 'string', description: 'recipient nickname, or "all"' },
|
|
149
149
|
path: { type: 'string', description: 'path of the file to send' },
|
|
150
150
|
note: { type: 'string', description: 'one line saying what the file is' },
|
|
151
|
-
channel: { type: 'string', description: 'channel name
|
|
151
|
+
channel: { type: 'string', description: 'channel name. Omit it only while one channel is configured; past that, omitting it is refused rather than guessed' },
|
|
152
152
|
reply_to: { type: 'string', description: 'the ts of the message being answered, as shown by inbox' },
|
|
153
153
|
},
|
|
154
154
|
required: ['to', 'path'],
|
|
@@ -161,6 +161,12 @@ const TOOLS = [
|
|
|
161
161
|
},
|
|
162
162
|
];
|
|
163
163
|
|
|
164
|
+
// Says which of the two happened, because "no such channel: undefined" reads as a
|
|
165
|
+
// broken tool rather than as a missing argument.
|
|
166
|
+
const noChannel = (config, wanted) => (isAmbiguous(config, wanted)
|
|
167
|
+
? `name the channel: ${config.channels.map((channel) => channel.name).join(', ')}`
|
|
168
|
+
: `no such channel: ${wanted ?? '(none configured)'}`);
|
|
169
|
+
|
|
164
170
|
const isBlank = (value) => value === undefined || value === null || (typeof value === 'string' && !value.trim());
|
|
165
171
|
|
|
166
172
|
// One poller per machine, elected by a lock file. Several agent sessions share
|
|
@@ -241,11 +247,12 @@ function modeInstruction(mode, channel) {
|
|
|
241
247
|
//
|
|
242
248
|
// A channel that throws is caught here rather than at the caller, so one broken
|
|
243
249
|
// channel costs its own messages instead of everybody else's.
|
|
244
|
-
export async function pollOnce(config) {
|
|
245
|
-
const client = slackClient(config.bot_token);
|
|
250
|
+
export async function pollOnce(config, { budgetMs = null } = {}) {
|
|
246
251
|
const channels = pollableChannels(config);
|
|
252
|
+
const deadline = budgetMs ? Date.now() + budgetMs : null;
|
|
253
|
+
const client = slackClient(config.bot_token, { deadline });
|
|
247
254
|
const polled = await mapLimit(channels, CHANNEL_CONCURRENCY, (channel) =>
|
|
248
|
-
pollChannel(client, channel, { since: readCursor(channel.id), myNickname: config.nickname })
|
|
255
|
+
pollChannel(client, channel, { since: readCursor(channel.id), myNickname: config.nickname, deadline })
|
|
249
256
|
.catch((error) => ({ ok: false, reason: error.message, items: [] })));
|
|
250
257
|
|
|
251
258
|
let added = 0;
|
|
@@ -286,7 +293,7 @@ function recipientKind(config, to) {
|
|
|
286
293
|
|
|
287
294
|
async function sendText(config, { to, text, channel, replyTo }) {
|
|
288
295
|
const target = findChannel(config, channel);
|
|
289
|
-
if (!target) return
|
|
296
|
+
if (!target) return noChannel(config, channel);
|
|
290
297
|
|
|
291
298
|
const chain = chainOf(replyTo);
|
|
292
299
|
if (chain.hop > MAX_HOPS) {
|
|
@@ -401,9 +408,9 @@ async function call(name, args, session) {
|
|
|
401
408
|
if (missing.length) return `missing or empty: ${missing.join(', ')}`;
|
|
402
409
|
|
|
403
410
|
const config = loadConfig();
|
|
404
|
-
// Said to an agent, which will pass it on. Naming the terminal matters: setup
|
|
405
|
-
// refuses a pipe, so an agent that tries to run it from a tool gets a bare
|
|
406
|
-
// refusal and tells the user the wrong thing.
|
|
411
|
+
// Said to an agent, which will pass it on. Naming the terminal matters: setup
|
|
412
|
+
// refuses a pipe, so an agent that tries to run it from a tool gets a bare
|
|
413
|
+
// refusal and tells the user the wrong thing.
|
|
407
414
|
if (!config) return 'agent-wire is not configured yet. Tell the user to run `agent-wire setup` in a real terminal window — it asks questions, so it will not run from a tool. Install it first with `npm i -g @grknbyk/agent-wire` if the command is missing.';
|
|
408
415
|
|
|
409
416
|
// The card reaches the user through a tool rather than a shell, because a
|
|
@@ -437,7 +444,7 @@ async function call(name, args, session) {
|
|
|
437
444
|
|
|
438
445
|
if (name === 'members') {
|
|
439
446
|
const target = findChannel(config, args.channel);
|
|
440
|
-
if (!target) return
|
|
447
|
+
if (!target) return noChannel(config, args.channel);
|
|
441
448
|
|
|
442
449
|
const result = await listMembers(slackClient(config.bot_token), target.id);
|
|
443
450
|
if (!result.ok) return `Slack said: ${result.reason}`;
|
|
@@ -481,7 +488,7 @@ async function call(name, args, session) {
|
|
|
481
488
|
|
|
482
489
|
if (name === 'send_file') {
|
|
483
490
|
const target = findChannel(config, args.channel);
|
|
484
|
-
if (!target) return
|
|
491
|
+
if (!target) return noChannel(config, args.channel);
|
|
485
492
|
if (!existsSync(args.path)) return `no such file: ${args.path}`;
|
|
486
493
|
|
|
487
494
|
const result = await postFile(config, {
|
package/src/slack.mjs
CHANGED
|
@@ -11,6 +11,13 @@ import { installedVersion } from './version.mjs';
|
|
|
11
11
|
const API = 'https://slack.com/api/';
|
|
12
12
|
const RATE_LIMITED = 429;
|
|
13
13
|
const DEFAULT_RETRY_SECONDS = 5;
|
|
14
|
+
|
|
15
|
+
// conversations.history is rate limited per workspace, and the whole team shares
|
|
16
|
+
// one app. Retrying a 429 forever meant a rate-limited machine waited forever, on
|
|
17
|
+
// every prompt, because the prompt hook polls too. Three tries, and a request that
|
|
18
|
+
// hangs is cut rather than waited on.
|
|
19
|
+
const MAX_RETRIES = 3;
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 15000;
|
|
14
21
|
const PAGE_LIMIT = 100;
|
|
15
22
|
const MAX_PAGES = 10;
|
|
16
23
|
const MEMBER_LIMIT = 200;
|
|
@@ -56,17 +63,33 @@ export async function mapLimit(items, limit, run) {
|
|
|
56
63
|
// conversations.* reject a JSON body and chat.postMessage needs one for metadata,
|
|
57
64
|
// so the client speaks both and the caller picks per method. The token rides along
|
|
58
65
|
// because downloading a file is a plain fetch, not an API call.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
// A failure comes back shaped like Slack's own answer rather than thrown, because
|
|
67
|
+
// every caller already reads result.ok and none of them is wrapped in a try.
|
|
68
|
+
export function slackClient(token, { deadline = null } = {}) {
|
|
69
|
+
const timeoutFor = () => {
|
|
70
|
+
if (!deadline) return REQUEST_TIMEOUT_MS;
|
|
71
|
+
return Math.max(1, Math.min(REQUEST_TIMEOUT_MS, deadline - Date.now()));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const request = async (method, init, attempt = 1) => {
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetch(API + method, {
|
|
78
|
+
...init,
|
|
79
|
+
signal: AbortSignal.timeout(timeoutFor()),
|
|
80
|
+
headers: { authorization: `Bearer ${token}`, ...init.headers },
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return { ok: false, error: error.name === 'TimeoutError' ? 'timeout' : 'network_error' };
|
|
84
|
+
}
|
|
65
85
|
if (response.status !== RATE_LIMITED) return response.json();
|
|
66
86
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
return
|
|
87
|
+
const waitMs = Number(response.headers.get('retry-after') || DEFAULT_RETRY_SECONDS) * 1000;
|
|
88
|
+
if (attempt >= MAX_RETRIES) return { ok: false, error: 'ratelimited' };
|
|
89
|
+
if (deadline && Date.now() + waitMs > deadline) return { ok: false, error: 'ratelimited' };
|
|
90
|
+
|
|
91
|
+
await new Promise((done) => setTimeout(done, waitMs));
|
|
92
|
+
return request(method, init, attempt + 1);
|
|
70
93
|
};
|
|
71
94
|
|
|
72
95
|
return {
|
|
@@ -275,7 +298,7 @@ async function humanItem(client, message, channel, namesById) {
|
|
|
275
298
|
};
|
|
276
299
|
}
|
|
277
300
|
|
|
278
|
-
async function agentItem(client, message, channel, payload) {
|
|
301
|
+
async function agentItem(client, message, channel, payload, inHurry = false) {
|
|
279
302
|
const parsed = parseMessage(message.text ?? '');
|
|
280
303
|
const text = parsed?.text ?? fromSlackText(message.text ?? '');
|
|
281
304
|
const authorship = checkAuthorship({
|
|
@@ -292,8 +315,13 @@ async function agentItem(client, message, channel, payload) {
|
|
|
292
315
|
|
|
293
316
|
// Fetched only after the signature holds. Pulling bytes for a message that
|
|
294
317
|
// failed verification is doing an impostor's downloading for them.
|
|
318
|
+
//
|
|
319
|
+
// Skipped entirely when the caller is against a deadline: a prompt hook cannot
|
|
320
|
+
// wait on somebody's 15 MB export, and the file is still in Slack for the next
|
|
321
|
+
// poll to fetch.
|
|
295
322
|
const isTrusted = authorship.verdict === 'signed' || authorship.verdict === 'new';
|
|
296
|
-
const
|
|
323
|
+
const wanted = payload.file && isTrusted && !inHurry;
|
|
324
|
+
const files = wanted ? await downloadById(client, payload.file) : [];
|
|
297
325
|
|
|
298
326
|
return {
|
|
299
327
|
ts: message.ts,
|
|
@@ -316,12 +344,17 @@ async function agentItem(client, message, channel, payload) {
|
|
|
316
344
|
// Returns items oldest-first. With `oldest` set, Slack answers from the old end of
|
|
317
345
|
// the range, so messages[0] is the high-water mark and a burst wider than one page
|
|
318
346
|
// is carried across polls rather than dropped.
|
|
319
|
-
export async function pollChannel(client, channel, { since, myNickname }) {
|
|
347
|
+
export async function pollChannel(client, channel, { since, myNickname, deadline = null }) {
|
|
320
348
|
const items = [];
|
|
321
349
|
let newest = since;
|
|
322
350
|
let cursor = '';
|
|
323
351
|
|
|
324
352
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
353
|
+
// Out of time. What has been read is returned and the cursor moves with
|
|
354
|
+
// it, so the next poll starts where this one stopped rather than repeating
|
|
355
|
+
// the work. A partial answer now beats a whole one after the prompt.
|
|
356
|
+
if (deadline && Date.now() > deadline) break;
|
|
357
|
+
|
|
325
358
|
const history = await client.form('conversations.history', {
|
|
326
359
|
channel: channel.id,
|
|
327
360
|
limit: PAGE_LIMIT,
|
|
@@ -349,7 +382,7 @@ export async function pollChannel(client, channel, { since, myNickname }) {
|
|
|
349
382
|
const payload = message.metadata?.event_type === METADATA_EVENT ? message.metadata.event_payload : null;
|
|
350
383
|
if (payload) {
|
|
351
384
|
if (payload.from === myNickname) continue; // our own post, already in our log
|
|
352
|
-
items.push(await agentItem(client, message, channel, payload));
|
|
385
|
+
items.push(await agentItem(client, message, channel, payload, Boolean(deadline)));
|
|
353
386
|
continue;
|
|
354
387
|
}
|
|
355
388
|
if (message.bot_id) continue; // another app, or the bare upload our own sidecar describes
|