@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.
@@ -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.14.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; defaults to the first configured channel' } },
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; defaults to the first configured channel' },
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; defaults to the first configured channel' },
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: [{ name: 'channel', description: 'Channel name. Omit it when only one is configured.', required: false }],
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 `no such channel: ${channel ?? '(none configured)'}`;
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 `no such channel: ${args.channel ?? '(none configured)'}`;
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 `no such channel: ${args.channel ?? '(none configured)'}`;
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
- export function slackClient(token) {
60
- const request = async (method, init) => {
61
- const response = await fetch(API + method, {
62
- ...init,
63
- headers: { authorization: `Bearer ${token}`, ...init.headers },
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 wait = Number(response.headers.get('retry-after') || DEFAULT_RETRY_SECONDS);
68
- await new Promise((done) => setTimeout(done, wait * 1000));
69
- return request(method, init);
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 files = payload.file && isTrusted ? await downloadById(client, payload.file) : [];
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