@grknbyk/agent-wire 0.14.0 → 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 +35 -16
- 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
|
|
@@ -185,12 +191,24 @@ const MODE_SUMMARY = {
|
|
|
185
191
|
read: 'the messages themselves, in every prompt',
|
|
186
192
|
};
|
|
187
193
|
|
|
194
|
+
const CHANNEL_ARGUMENT = [{ name: 'channel', description: 'Channel name. Omit it when only one is configured.', required: false }];
|
|
195
|
+
|
|
196
|
+
// on is what a person reaches for after off, and the shell has taken it since
|
|
197
|
+
// before there were three modes. A word that works in one place and not the other
|
|
198
|
+
// is the whole surprise, so it means ask here too.
|
|
199
|
+
const PROMPT_ALIAS = { on: 'ask' };
|
|
200
|
+
|
|
188
201
|
const PROMPTS = [
|
|
189
202
|
...MODES.map((mode) => ({
|
|
190
203
|
name: mode,
|
|
191
204
|
description: `Set a channel to ${mode} for this session — ${MODE_SUMMARY[mode]}`,
|
|
192
|
-
arguments:
|
|
205
|
+
arguments: CHANNEL_ARGUMENT,
|
|
193
206
|
})),
|
|
207
|
+
{
|
|
208
|
+
name: 'on',
|
|
209
|
+
description: `Set a channel to ask for this session — ${MODE_SUMMARY.ask}`,
|
|
210
|
+
arguments: CHANNEL_ARGUMENT,
|
|
211
|
+
},
|
|
194
212
|
{ name: 'status', description: 'Show the agent-wire status card', arguments: [] },
|
|
195
213
|
];
|
|
196
214
|
|
|
@@ -229,11 +247,12 @@ function modeInstruction(mode, channel) {
|
|
|
229
247
|
//
|
|
230
248
|
// A channel that throws is caught here rather than at the caller, so one broken
|
|
231
249
|
// channel costs its own messages instead of everybody else's.
|
|
232
|
-
export async function pollOnce(config) {
|
|
233
|
-
const client = slackClient(config.bot_token);
|
|
250
|
+
export async function pollOnce(config, { budgetMs = null } = {}) {
|
|
234
251
|
const channels = pollableChannels(config);
|
|
252
|
+
const deadline = budgetMs ? Date.now() + budgetMs : null;
|
|
253
|
+
const client = slackClient(config.bot_token, { deadline });
|
|
235
254
|
const polled = await mapLimit(channels, CHANNEL_CONCURRENCY, (channel) =>
|
|
236
|
-
pollChannel(client, channel, { since: readCursor(channel.id), myNickname: config.nickname })
|
|
255
|
+
pollChannel(client, channel, { since: readCursor(channel.id), myNickname: config.nickname, deadline })
|
|
237
256
|
.catch((error) => ({ ok: false, reason: error.message, items: [] })));
|
|
238
257
|
|
|
239
258
|
let added = 0;
|
|
@@ -274,7 +293,7 @@ function recipientKind(config, to) {
|
|
|
274
293
|
|
|
275
294
|
async function sendText(config, { to, text, channel, replyTo }) {
|
|
276
295
|
const target = findChannel(config, channel);
|
|
277
|
-
if (!target) return
|
|
296
|
+
if (!target) return noChannel(config, channel);
|
|
278
297
|
|
|
279
298
|
const chain = chainOf(replyTo);
|
|
280
299
|
if (chain.hop > MAX_HOPS) {
|
|
@@ -389,9 +408,9 @@ async function call(name, args, session) {
|
|
|
389
408
|
if (missing.length) return `missing or empty: ${missing.join(', ')}`;
|
|
390
409
|
|
|
391
410
|
const config = loadConfig();
|
|
392
|
-
// Said to an agent, which will pass it on. Naming the terminal matters: setup
|
|
393
|
-
// refuses a pipe, so an agent that tries to run it from a tool gets a bare
|
|
394
|
-
// 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.
|
|
395
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.';
|
|
396
415
|
|
|
397
416
|
// The card reaches the user through a tool rather than a shell, because a
|
|
@@ -425,7 +444,7 @@ async function call(name, args, session) {
|
|
|
425
444
|
|
|
426
445
|
if (name === 'members') {
|
|
427
446
|
const target = findChannel(config, args.channel);
|
|
428
|
-
if (!target) return
|
|
447
|
+
if (!target) return noChannel(config, args.channel);
|
|
429
448
|
|
|
430
449
|
const result = await listMembers(slackClient(config.bot_token), target.id);
|
|
431
450
|
if (!result.ok) return `Slack said: ${result.reason}`;
|
|
@@ -469,7 +488,7 @@ async function call(name, args, session) {
|
|
|
469
488
|
|
|
470
489
|
if (name === 'send_file') {
|
|
471
490
|
const target = findChannel(config, args.channel);
|
|
472
|
-
if (!target) return
|
|
491
|
+
if (!target) return noChannel(config, args.channel);
|
|
473
492
|
if (!existsSync(args.path)) return `no such file: ${args.path}`;
|
|
474
493
|
|
|
475
494
|
const result = await postFile(config, {
|
|
@@ -526,7 +545,7 @@ export function serve() {
|
|
|
526
545
|
if (!asked) return write({ jsonrpc: '2.0', id: message.id, error: { code: -32602, message: `no prompt named ${message.params.name}` } });
|
|
527
546
|
const answer = asked.name === 'status'
|
|
528
547
|
? STATUS_INSTRUCTION
|
|
529
|
-
: modeInstruction(asked.name, message.params.arguments?.channel);
|
|
548
|
+
: modeInstruction(PROMPT_ALIAS[asked.name] ?? asked.name, message.params.arguments?.channel);
|
|
530
549
|
return write({ jsonrpc: '2.0', id: message.id, result: answer });
|
|
531
550
|
}
|
|
532
551
|
if (message.method === 'ping') return write({ jsonrpc: '2.0', id: message.id, result: {} });
|
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
|