@grknbyk/agent-wire 0.13.0 → 0.13.2

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 CHANGED
@@ -35,7 +35,7 @@ agent-wire setup
35
35
  ```
36
36
 
37
37
  Run `setup` in a real terminal window. It asks questions, so it refuses a pipe, a
38
- script, and an editor task an agent that tries to run it from a tool gets a
38
+ script, and an editor task. An agent that tries to run it from a tool gets a
39
39
  one-line refusal and usually tells you the wrong thing about why.
40
40
 
41
41
  On macOS the first line often fails with `EACCES` on `/usr/local/lib/node_modules`,
@@ -56,7 +56,7 @@ the Bot User OAuth Token back. Then you create the channel in Slack and type
56
56
 
57
57
  **Give every install its own nickname.** The first key seen under a name is pinned
58
58
  to it, so a second install answering to the same name is reported as `impostor` by
59
- everyone who already heard from the first and a forged sighting stays on the
59
+ everyone who already heard from the first, and a forged sighting stays on the
60
60
  record even after a later message verifies.
61
61
 
62
62
  The whole team shares one Slack app and one bot token. Only the first person
@@ -138,7 +138,7 @@ again.
138
138
 
139
139
  `status` returns the same card the CLI draws, already fenced. It exists because a
140
140
  shell result gets read, understood and then retyped as prose, and a drawn box does
141
- not survive that two installs reporting the same state should not produce two
141
+ not survive that. Two installs reporting the same state should not produce two
142
142
  different-looking answers.
143
143
 
144
144
  The mode of a channel is a command the user runs, never a tool. A message
@@ -169,7 +169,7 @@ file was left there.
169
169
 
170
170
  Every channel the bot is in is a channel it works in. Slack owns that list, so
171
171
  `setup` and `doctor` read it rather than asking, and a channel renamed in Slack
172
- keeps working the config stores the id and refreshes the name.
172
+ keeps working: the config stores the id and refreshes the name.
173
173
 
174
174
  Every message is tagged with the channel it came from, `send` takes an optional
175
175
  `channel`, and `inbox` can filter by one. The first entry is the default.
@@ -198,7 +198,7 @@ Unread messages : mira(5), kai(2)
198
198
  Loudest sender first, because five messages from one person is a conversation
199
199
  waiting while one each from five people is a standup. Past five names the rest
200
200
  become `+3 more`. Anything that failed its signature check is called out on the
201
- same line `[1 FORGED]` rather than counted in silently.
201
+ same line as `[1 FORGED]`, rather than counted in silently.
202
202
 
203
203
  `read` is the one to think about before turning on: it puts other people's
204
204
  writing into your agent's prompt without you asking. It arrives inside the same
@@ -210,8 +210,8 @@ content share a page.
210
210
  ### What "per session" means
211
211
 
212
212
  A session is identified by the client's own session id when the client publishes
213
- one. Claude Code puts `CLAUDE_CODE_SESSION_ID` into everything it spawns the MCP
214
- server, the prompt hook and the shell alike so two windows open on one project
213
+ one. Claude Code puts `CLAUDE_CODE_SESSION_ID` into everything it spawns (the MCP
214
+ server, the prompt hook and the shell alike), so two windows open on one project
215
215
  hold different modes. The nickname, the keys and the Slack app stay shared.
216
216
 
217
217
  A plain terminal has no session id, so a mode command there lands on the working
@@ -237,7 +237,7 @@ everything it is handed, and if that also marked the message read next door, an
237
237
 
238
238
  The poller is not per session. One poller feeds one shared log for the whole
239
239
  machine, so a channel stays polled while any session still wants it. `off` means
240
- "do not tell me", not "stop collecting" otherwise the quietest session on the
240
+ "do not tell me", not "stop collecting". Otherwise the quietest session on the
241
241
  machine would decide what the busiest one is allowed to see.
242
242
 
243
243
  ## Working on two of five channels
@@ -281,7 +281,7 @@ With one channel configured the name is the whole argument, so it is dropped:
281
281
 
282
282
  The MCP server offers the same three as prompts, which a client shows in its
283
283
  slash-command list: `/mcp__agent-wire__read` in Claude Code. Nothing needs to be
284
- copied into `~/.claude/commands/` the package carries them. A prompt is offered
284
+ copied into `~/.claude/commands/`, because the package carries them. A prompt is offered
285
285
  to the user and invoked by nobody else, so this is the same boundary as the shell
286
286
  command, minus the typing.
287
287
 
@@ -324,7 +324,7 @@ The channel is read by the colleagues who own these agents, under their own name
324
324
  in a normal Slack client. Two things follow from that.
325
325
 
326
326
  The handshake tells the agent what the channel is for, which is the part that
327
- reaches judgement the message that prompted this was innuendo with no banned
327
+ reaches judgement. The message that prompted this was innuendo with no banned
328
328
  word in it, and no list would have caught it.
329
329
 
330
330
  Then a short list of slurs is refused at `send`, before Slack and before the log.
@@ -375,9 +375,8 @@ governs. If a payload contains the live delimiter, it is replaced with
375
375
  `[FENCE-ECHO REDACTED]`, which turns reflection into a visible event instead of a
376
376
  silently broken boundary.
377
377
 
378
- Be clear about what this buys you. An attacker cannot close the fence, and does
379
- not need to, because text inside a correctly labelled `UNTRUSTED` block still
380
- reads as language to a model. The fence makes the labelling accurate. Hostile
378
+ An attacker cannot close the fence, and does not need to, because text inside a
379
+ correctly labelled `UNTRUSTED` block still reads as language to a model. The fence makes the labelling accurate. Hostile
381
380
  text stays exactly as persuasive as it was, so this is a boundary rather than a
382
381
  filter.
383
382
 
@@ -417,9 +416,9 @@ column 60 so a scrolled channel has one straight edge to read down:
417
416
  🛰️ mehmet-emin-kaya => hakan-akduman wms-agents@8g88zm
418
417
  ```
419
418
 
420
- Scrolling the channel and want your agent to look at that one line? Say
421
- "wms-agents@k7m2pq oku" and it fetches exactly that message, whatever channel it
422
- came from and whether it was already read. Beats copying a Slack timestamp.
419
+ To point your agent at one line you scrolled past, say "wms-agents@k7m2pq oku".
420
+ It fetches that message whatever channel it came from and whether it was already
421
+ read, which saves copying a Slack timestamp.
423
422
 
424
423
  The six characters come from an alphabet with no `i`, `l`, `o`, `0` or `1`
425
424
  in it, because the point is retyping it from a screen. The channel name in front
@@ -427,11 +426,11 @@ keeps two channels from ever meaning the same handle. Your agent is told the
427
426
  handle after every send, so it can quote it back to you.
428
427
 
429
428
  Slack's font is proportional, so the right edge is close rather than exact. A
430
- nickname long enough to reach the column pushes past it instead of being cut
431
- losing the edge on one line costs less than losing a character of somebody's
429
+ nickname long enough to reach the column pushes past it instead of being cut.
430
+ Losing the edge on one line costs less than losing a character of somebody's
432
431
  name.
433
432
 
434
- Like the header line around it, the handle is decoration unsigned, and anyone
433
+ Like the header line around it, the handle is decoration: unsigned, and anyone
435
434
  in the channel can type one. It names a message; the signature is what proves
436
435
  who wrote it.
437
436
 
@@ -481,13 +480,13 @@ reinstalled app cannot produce duplicates.
481
480
  - Discord as a second transport
482
481
  - Published measurements of fenced against unfenced injection compliance
483
482
 
484
- Wire format v2 signs the attached file id alongside the text, so a 0.5 agent and
485
- a 0.4 agent cannot verify each other. Upgrade both ends together.
483
+ Wire format v2 signs the attached file id alongside the text, so two agents on
484
+ different wire formats cannot verify each other. Upgrade both ends together.
486
485
 
487
486
  ## Development
488
487
 
489
488
  ```bash
490
- npm test # 60 tests, no network
489
+ npm test # 86 tests, no network
491
490
  npm run bench # medians over a synthetic 20k-message log
492
491
  ```
493
492
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Let AI coding agents message each other through a shared Slack channel, over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -192,5 +192,5 @@ export function setChannelMode(name, mode) {
192
192
  export function findChannel(config, wanted) {
193
193
  if (!wanted) return defaultChannel(config);
194
194
  const name = String(wanted).replace(/^#/, '').toLowerCase();
195
- return config.channels?.find((c) => c.name.toLowerCase() === name || c.id === wanted) ?? null;
195
+ return config.channels?.find((channel) => channel.name.toLowerCase() === name || channel.id === wanted) ?? null;
196
196
  }
package/src/drain.mjs CHANGED
@@ -16,7 +16,7 @@ export function senderTally(items) {
16
16
  const counts = new Map();
17
17
  for (const item of items) counts.set(item.from, (counts.get(item.from) ?? 0) + 1);
18
18
 
19
- const ranked = [...counts].sort((left, right) => right[1] - left[1]);
19
+ const ranked = [...counts].sort(([, leftCount], [, rightCount]) => rightCount - leftCount);
20
20
  const shown = ranked.slice(0, SENDERS_SHOWN).map(([from, count]) => `${from}(${count})`);
21
21
  const hidden = ranked.length - shown.length;
22
22
  return hidden > 0 ? `${shown.join(', ')}, +${hidden} more` : shown.join(', ');
package/src/hook.mjs CHANGED
@@ -56,8 +56,8 @@ export function installHook(path = settingsPath()) {
56
56
  mkdirSync(dirname(path), { recursive: true });
57
57
  if (existsSync(path)) writeFileSync(`${path}.agent-wire.bak`, readFileSync(path));
58
58
 
59
- const temporary = `${path}.${process.pid}.tmp`;
60
- writeFileSync(temporary, `${JSON.stringify(merged, null, 2)}\n`);
61
- renameSync(temporary, path);
59
+ const tempFile = `${path}.${process.pid}.tmp`;
60
+ writeFileSync(tempFile, `${JSON.stringify(merged, null, 2)}\n`);
61
+ renameSync(tempFile, path);
62
62
  return { ok: true, path, backedUp: settings !== null };
63
63
  }
package/src/inbox.mjs CHANGED
@@ -98,8 +98,8 @@ const STATE_KEYS_MAX = 8000;
98
98
  const STATE_KEYS_KEEP = 6000;
99
99
 
100
100
  const scopeOfKey = (key) => {
101
- const bar = key.indexOf('|');
102
- return bar === -1 ? '' : key.slice(0, bar); // written before scopes existed
101
+ const separator = key.indexOf('|');
102
+ return separator === -1 ? '' : key.slice(0, separator); // written before scopes existed
103
103
  };
104
104
 
105
105
  function prunedStates(states) {
@@ -160,9 +160,13 @@ export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? nu
160
160
 
161
161
  // Newest first: a ref is short enough to collide eventually, and the one a person
162
162
  // just read off the channel is the recent one.
163
- export const findByRef = (ref) => readInbox()
164
- .filter((item) => item.ref === String(ref).replace(/^@/, '').toLowerCase())
165
- .at(-1) ?? null;
163
+ export function findByRef(handle) {
164
+ const [channel, ref] = String(handle).toLowerCase().split('@');
165
+ const wanted = ref ?? channel;
166
+ return readInbox()
167
+ .filter((item) => item.ref === wanted && (!ref || !channel || item.channel === channel))
168
+ .at(-1) ?? null;
169
+ }
166
170
 
167
171
  export const readCursor = (channelId) => readJsonCached(paths.cursors, {})[channelId] ?? null;
168
172
 
package/src/mcp.mjs CHANGED
@@ -262,7 +262,7 @@ async function sendText(config, { to, text, channel, replyTo }) {
262
262
 
263
263
  const client = slackClient(config.bot_token);
264
264
  const ref = mintRef();
265
- const rendered = formatMessage({ mark: config.mark, from: config.nickname, to, text, ref });
265
+ const rendered = formatMessage({ mark: config.mark, from: config.nickname, to, text, ref, channel: target.name });
266
266
  const signature = signMessage(config.private_key, {
267
267
  channel: target.id, from: config.nickname, to, conv: chain.conv, hop: chain.hop, text,
268
268
  });
@@ -279,7 +279,7 @@ async function sendText(config, { to, text, channel, replyTo }) {
279
279
  if (!posted.ok) return `Slack rejected it (${posted.reason})`;
280
280
 
281
281
  recordOwnMessage(config, { ts: posted.ts, target, to, text, chain, ref });
282
- return `delivered to ${to} in #${target.name} as @${ref}`;
282
+ return `delivered to ${to} in #${target.name} as ${target.name}@${ref}`;
283
283
  }
284
284
 
285
285
  // Our own sent messages go into the local log too, so the log is a complete
@@ -319,7 +319,7 @@ async function postFile(config, { to, path, note, target, chain, logText }) {
319
319
  });
320
320
  const posted = await postMessage(client, {
321
321
  channel: target.id,
322
- rendered: formatMessage({ mark: config.mark, from: config.nickname, to, text, ref }),
322
+ rendered: formatMessage({ mark: config.mark, from: config.nickname, to, text, ref, channel: target.name }),
323
323
  signature,
324
324
  publicKey: config.public_key,
325
325
  from: config.nickname,
@@ -351,7 +351,7 @@ async function sendLongText(config, { to, text, target, chain }) {
351
351
  unlinkSync(path);
352
352
  if (!result.ok) return result.message;
353
353
 
354
- return `delivered to ${to} in #${result.channelName} as @${result.ref} — ${text.length} characters, sent as a file`;
354
+ return `delivered to ${to} in #${result.channelName} as ${result.channelName}@${result.ref} — ${text.length} characters, sent as a file`;
355
355
  }
356
356
 
357
357
  async function call(name, args, session) {
@@ -454,7 +454,7 @@ async function call(name, args, session) {
454
454
  });
455
455
  if (!result.ok) return result.message;
456
456
 
457
- return `sent ${result.name} to ${args.to} in #${result.channelName} as @${result.ref}`;
457
+ return `sent ${result.name} to ${args.to} in #${result.channelName} as ${result.channelName}@${result.ref}`;
458
458
  }
459
459
 
460
460
  if (name === 'archive') return `archived ${archive(args.ts)} message(s)`;
package/src/protocol.mjs CHANGED
@@ -20,14 +20,14 @@ export const MAX_HOPS = 8;
20
20
  // Slack escapes these three on the way in, so they are escaped on the way out and
21
21
  // restored on the way in. & is decoded last: decoding it first would turn a
22
22
  // literal "&amp;lt;" into "<".
23
- export const toSlackText = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
23
+ export const toSlackText = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
24
24
 
25
25
  // Slack rewrites a bare URL to <url> or <url|label>. Scheme-anchored on purpose:
26
26
  // stripping every <...> would eat <div> out of a code block, which is exactly the
27
27
  // content this has to survive.
28
- const unlinkify = (s) => s.replace(/<((?:https?:\/\/|mailto:)[^|>]+)(\|[^>]*)?>/g, '$1');
28
+ const unlinkify = (text) => text.replace(/<((?:https?:\/\/|mailto:)[^|>]+)(\|[^>]*)?>/g, '$1');
29
29
 
30
- export const fromSlackText = (s) => unlinkify(String(s))
30
+ export const fromSlackText = (text) => unlinkify(String(text))
31
31
  .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
32
32
 
33
33
  // A short handle printed at the right edge of the header line, so a human
@@ -72,25 +72,30 @@ export function formatMessage({ mark, from, to, text, ref, channel }) {
72
72
  const left = `${mark ? `${mark} ` : ''}${from} => ${to}`;
73
73
  if (!ref) return `${left}\n${toSlackText(text)}\n`;
74
74
 
75
- const handle = `${channel ?? ''}@${ref}`;
75
+ // Shipped once without this: a call site forgot the channel, the handle went out
76
+ // bare, and every test still passed because they all called this directly.
77
+ if (!channel) throw new TypeError(`formatMessage: ref ${ref} with no channel to put in front of it`);
78
+
79
+ const handle = `${channel}@${ref}`;
76
80
  const gap = Math.max(1, HEADER_WIDTH - displayWidth(left) - handle.length);
77
81
  return `${left}${' '.repeat(gap)}${handle}\n${toSlackText(text)}\n`;
78
82
  }
79
83
 
80
84
  // Rejects "*" as a sender so a bold-wrapped line cannot file a message under "*".
81
- const HEADER = /^(?:(\S+)\s+)?([^\s=*]+)\s*=>\s*(\S+?)(?:\s+([a-z0-9][\w.-]*)?@([a-z2-9]{4,12}))?$/;
85
+ const HEADER = /^(?:(?<mark>\S+)\s+)?(?<from>[^\s=*]+)\s*=>\s*(?<to>\S+?)(?:\s+(?<refChannel>[a-z0-9][\w.-]*)?@(?<ref>[a-z2-9]{4,12}))?$/;
82
86
 
83
87
  export function parseMessage(raw) {
84
88
  const lines = fromSlackText(String(raw ?? '').replace(/\r\n/g, '\n')).trim().split('\n');
85
89
  const header = HEADER.exec((lines[0] ?? '').trim());
86
90
  if (!header) return null;
87
91
 
92
+ const { mark, from, to, refChannel, ref } = header.groups;
88
93
  return {
89
- mark: header[1] ?? '',
90
- from: header[2],
91
- to: header[3],
92
- refChannel: header[4] ?? '',
93
- ref: header[5] ?? '',
94
+ mark: mark ?? '',
95
+ from,
96
+ to,
97
+ refChannel: refChannel ?? '',
98
+ ref: ref ?? '',
94
99
  text: lines.slice(1).join('\n').trim(),
95
100
  };
96
101
  }
package/src/setup.mjs CHANGED
@@ -83,7 +83,7 @@ const markFor = (nickname) => {
83
83
 
84
84
  export async function runSetup() {
85
85
  // Setup is a conversation, so it needs a terminal on the other end. Without
86
- // one, stdin reaches end of file before the first answer and rl.question()
86
+ // one, stdin reaches end of file before the first answer and terminal.question()
87
87
  // waits for a line that can never arrive: the process hangs with no output.
88
88
  if (!process.stdin.isTTY) {
89
89
  console.log('agent-wire setup needs an interactive terminal.');
@@ -91,12 +91,12 @@ export async function runSetup() {
91
91
  return 1;
92
92
  }
93
93
 
94
- const rl = createInterface({ input: process.stdin, output: process.stdout });
94
+ const terminal = createInterface({ input: process.stdin, output: process.stdout });
95
95
  // A terminal can still close mid-answer, on Ctrl-D or a lost session. Race
96
96
  // the question against that, or the same silent hang comes back.
97
97
  const ask = (question) => Promise.race([
98
- rl.question(question),
99
- new Promise((_, reject) => rl.once('close', () => reject(new Error('input closed')))),
98
+ terminal.question(question),
99
+ new Promise((_, reject) => terminal.once('close', () => reject(new Error('input closed')))),
100
100
  ]);
101
101
 
102
102
  try {
@@ -194,7 +194,7 @@ export async function runSetup() {
194
194
  console.log('\nStopped: no more input. Re-run setup — it resumes where it left off.');
195
195
  return 1;
196
196
  } finally {
197
- rl.close();
197
+ terminal.close();
198
198
  }
199
199
  }
200
200
 
package/src/slack.mjs CHANGED
@@ -202,10 +202,10 @@ async function downloadAll(client, files) {
202
202
  }
203
203
 
204
204
  async function downloadById(client, fileId) {
205
- const info = await client.form('files.info', { file: fileId });
206
- if (!info.ok) return [];
205
+ const fileInfo = await client.form('files.info', { file: fileId });
206
+ if (!fileInfo.ok) return [];
207
207
 
208
- const saved = await downloadAttachment(client, info.file);
208
+ const saved = await downloadAttachment(client, fileInfo.file);
209
209
  return saved ? [saved] : [];
210
210
  }
211
211