@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.
@@ -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.1",
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
@@ -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 `no such channel: ${channel ?? '(none configured)'}`;
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 `no such channel: ${args.channel ?? '(none configured)'}`;
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 `no such channel: ${args.channel ?? '(none configured)'}`;
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
- 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