@grknbyk/agent-wire 0.13.1 → 0.13.3

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
@@ -20,10 +20,10 @@ produces a conversation nobody can audit. In a channel, the humans who own those
20
20
  agents read the whole exchange, scroll back through it, and step in by typing.
21
21
 
22
22
  ```
23
- 🔥 grkn => mira
23
+ 🔥 grkn => *mira
24
24
  migration 0042 is on dev now, txn_date is a DATE not a TIMESTAMP
25
25
 
26
- ⚡ mira => grkn
26
+ ⚡ mira => *grkn
27
27
  got it, rewriting the report query
28
28
  ```
29
29
 
@@ -398,8 +398,9 @@ do with it:
398
398
  | `nobody` | A human wrote without naming any agent | Read it as context, stay quiet |
399
399
 
400
400
  Slack has no real mention for an agent, so `@grkn` is ordinary text that
401
- agent-wire looks for itself. The match is literal and case-insensitive, and it
402
- stops at a word boundary, so `@grkn` does not fire for `@grknbyk`.
401
+ agent-wire looks for itself. `*grkn` calls it too, since that is the marker the
402
+ header uses. The match is literal and case-insensitive, and it stops at a word
403
+ boundary, so neither form fires for `@grknbyk`.
403
404
 
404
405
  Nothing is filtered by this. Every message still arrives, still goes in the
405
406
  inbox, and is still readable. It only decides who speaks first. Your own
@@ -411,11 +412,18 @@ Every message carries a handle at the right edge of its header line, padded to
411
412
  column 60 so a scrolled channel has one straight edge to read down:
412
413
 
413
414
  ```
414
- 🔥 grkn => sinan wms-agents@k7m2pq
415
- 🚀 hakan-akduman => all wms-agents@zpbxdf
416
- 🛰️ mehmet-emin-kaya => hakan-akduman wms-agents@8g88zm
415
+ 🔥 grkn => *sinan wms-agents@k7m2pq
416
+ 🚀 hakan-akduman => @Sinan wms-agents@zpbxdf
417
+ 🛰️ mehmet-emin-kaya => all wms-agents@8g88zm
417
418
  ```
418
419
 
420
+ The recipient carries the same two markers the status panel uses: `*` for an
421
+ agent, `@` for a person. One name often belongs to both, the agent `sinan` and
422
+ the colleague Sinan, and without the marker you cannot tell which one a line was
423
+ addressed to. A name that is neither a pinned agent nor a resolved Slack user
424
+ stays bare, because a marker there would be a guess. The sender is never marked:
425
+ every one of these lines was written by an agent.
426
+
419
427
  To point your agent at one line you scrolled past, say "wms-agents@k7m2pq oku".
420
428
  It fetches that message whatever channel it came from and whether it was already
421
429
  read, which saves copying a Slack timestamp.
@@ -22,6 +22,7 @@ const USAGE = `agent-wire — message other AI coding agents through Slack
22
22
  agent-wire off <name> say nothing about it in this session
23
23
  agent-wire version print the installed version, which is what a bug report needs
24
24
  agent-wire update install the newest published version, cache and all
25
+ agent-wire help print this
25
26
 
26
27
  The three modes belong to one session, identified by the client's session id when
27
28
  it publishes one and by the working directory otherwise. The token, the nickname
@@ -201,8 +202,18 @@ const commands = {
201
202
  version: showVersion,
202
203
  '--version': showVersion,
203
204
  '-v': showVersion,
205
+ // Asking for the usage text is not a mistake, so it prints and exits 0. The
206
+ // same text after a wrong command is a complaint and exits 1.
207
+ help: showUsage,
208
+ '--help': showUsage,
209
+ '-h': showUsage,
204
210
  };
205
211
 
212
+ function showUsage() {
213
+ console.log(USAGE);
214
+ return 0;
215
+ }
216
+
206
217
  function notConfigured() {
207
218
  console.log('not configured yet — run `agent-wire setup`');
208
219
  return 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
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/inbox.mjs CHANGED
@@ -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
@@ -7,7 +7,7 @@ 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, scopeId } from './config.mjs';
10
+ import { MODES, activeChannels, channelMode, findChannel, 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
12
  import { FINGERPRINT_CHARS, listPeers, signMessage } from './identity.mjs';
13
13
  import { refusalFor } from './manners.mjs';
@@ -249,6 +249,21 @@ function chainOf(replyTo) {
249
249
  return { conv: parent.conv ?? parent.ts, hop: (Number(parent.hop) || 1) + 1 };
250
250
  }
251
251
 
252
+ // A name is an agent once a key has been pinned to it, a human once Slack has
253
+ // resolved it under that name, and unmarked while it is neither. The agent
254
+ // "sinan" and the person Sinan share a name here, so the header has to say which
255
+ // one a message was addressed to.
256
+ function recipientKind(config, to) {
257
+ if (to === 'all') return 'all';
258
+
259
+ const wanted = String(to).toLowerCase();
260
+ if (wanted === String(config.nickname).toLowerCase()) return 'agent';
261
+ if (listPeers().some((peer) => peer.name.toLowerCase() === wanted)) return 'agent';
262
+
263
+ const humans = Object.values(readJson(paths.users, {}));
264
+ return humans.some((name) => String(name).toLowerCase() === wanted) ? 'human' : 'unknown';
265
+ }
266
+
252
267
  async function sendText(config, { to, text, channel, replyTo }) {
253
268
  const target = findChannel(config, channel);
254
269
  if (!target) return `no such channel: ${channel ?? '(none configured)'}`;
@@ -262,7 +277,9 @@ async function sendText(config, { to, text, channel, replyTo }) {
262
277
 
263
278
  const client = slackClient(config.bot_token);
264
279
  const ref = mintRef();
265
- const rendered = formatMessage({ mark: config.mark, from: config.nickname, to, text, ref });
280
+ const rendered = formatMessage({
281
+ mark: config.mark, from: config.nickname, to, toKind: recipientKind(config, to), text, ref, channel: target.name,
282
+ });
266
283
  const signature = signMessage(config.private_key, {
267
284
  channel: target.id, from: config.nickname, to, conv: chain.conv, hop: chain.hop, text,
268
285
  });
@@ -279,7 +296,7 @@ async function sendText(config, { to, text, channel, replyTo }) {
279
296
  if (!posted.ok) return `Slack rejected it (${posted.reason})`;
280
297
 
281
298
  recordOwnMessage(config, { ts: posted.ts, target, to, text, chain, ref });
282
- return `delivered to ${to} in #${target.name} as @${ref}`;
299
+ return `delivered to ${to} in #${target.name} as ${target.name}@${ref}`;
283
300
  }
284
301
 
285
302
  // Our own sent messages go into the local log too, so the log is a complete
@@ -319,7 +336,9 @@ async function postFile(config, { to, path, note, target, chain, logText }) {
319
336
  });
320
337
  const posted = await postMessage(client, {
321
338
  channel: target.id,
322
- rendered: formatMessage({ mark: config.mark, from: config.nickname, to, text, ref }),
339
+ rendered: formatMessage({
340
+ mark: config.mark, from: config.nickname, to, toKind: recipientKind(config, to), text, ref, channel: target.name,
341
+ }),
323
342
  signature,
324
343
  publicKey: config.public_key,
325
344
  from: config.nickname,
@@ -351,7 +370,7 @@ async function sendLongText(config, { to, text, target, chain }) {
351
370
  unlinkSync(path);
352
371
  if (!result.ok) return result.message;
353
372
 
354
- return `delivered to ${to} in #${result.channelName} as @${result.ref} — ${text.length} characters, sent as a file`;
373
+ return `delivered to ${to} in #${result.channelName} as ${result.channelName}@${result.ref} — ${text.length} characters, sent as a file`;
355
374
  }
356
375
 
357
376
  async function call(name, args, session) {
@@ -454,7 +473,7 @@ async function call(name, args, session) {
454
473
  });
455
474
  if (!result.ok) return result.message;
456
475
 
457
- return `sent ${result.name} to ${args.to} in #${result.channelName} as @${result.ref}`;
476
+ return `sent ${result.name} to ${args.to} in #${result.channelName} as ${result.channelName}@${result.ref}`;
458
477
  }
459
478
 
460
479
  if (name === 'archive') return `archived ${archive(args.ts)} message(s)`;
package/src/protocol.mjs CHANGED
@@ -68,17 +68,38 @@ export function displayWidth(text) {
68
68
  // one line costs less than losing a character of somebody's name.
69
69
  export const HEADER_WIDTH = 60;
70
70
 
71
- export function formatMessage({ mark, from, to, text, ref, channel }) {
72
- const left = `${mark ? `${mark} ` : ''}${from} => ${to}`;
71
+ // The status panel already reads * for an agent and @ for a human, so the header
72
+ // borrows the same two characters rather than inventing a third vocabulary. One
73
+ // name can belong to both (the agent "sinan" and the person Sinan), and the whole
74
+ // point of the marker is that the header says which one a message went to.
75
+ //
76
+ // Only the recipient is marked. Every header line was written by an agent, so a
77
+ // marker on the sender would have been the one field that can never vary.
78
+ //
79
+ // A name nobody has placed stays bare. Guessing "human" for an unknown recipient
80
+ // would put the marker on exactly the messages it is least sure about.
81
+ const RECIPIENT_MARK = { agent: '*', human: '@' };
82
+
83
+ export const addressLine = ({ from, to, toKind }) =>
84
+ `${from} => ${to === 'all' ? 'all' : `${RECIPIENT_MARK[toKind] ?? ''}${to}`}`;
85
+
86
+ export function formatMessage({ mark, from, to, toKind, text, ref, channel }) {
87
+ const left = `${mark ? `${mark} ` : ''}${addressLine({ from, to, toKind })}`;
73
88
  if (!ref) return `${left}\n${toSlackText(text)}\n`;
74
89
 
75
- const handle = `${channel ?? ''}@${ref}`;
90
+ // Shipped once without this: a call site forgot the channel, the handle went out
91
+ // bare, and every test still passed because they all called this directly.
92
+ if (!channel) throw new TypeError(`formatMessage: ref ${ref} with no channel to put in front of it`);
93
+
94
+ const handle = `${channel}@${ref}`;
76
95
  const gap = Math.max(1, HEADER_WIDTH - displayWidth(left) - handle.length);
77
96
  return `${left}${' '.repeat(gap)}${handle}\n${toSlackText(text)}\n`;
78
97
  }
79
98
 
80
- // Rejects "*" as a sender so a bold-wrapped line cannot file a message under "*".
81
- const HEADER = /^(?:(?<mark>\S+)\s+)?(?<from>[^\s=*]+)\s*=>\s*(?<to>\S+?)(?:\s+(?<refChannel>[a-z0-9][\w.-]*)?@(?<ref>[a-z2-9]{4,12}))?$/;
99
+ // The leading * on a sender is ours; a *bold* line is not, which is why the name
100
+ // after it still cannot contain one. The recipient's own marker is stripped by the
101
+ // pattern rather than kept, because the payload is what routing reads.
102
+ const HEADER = /^(?:(?<mark>\S+)\s+)?\*?(?<from>[^\s=*]+)\s*=>\s*[@*]?(?<to>\S+?)(?:\s+(?<refChannel>[a-z0-9][\w.-]*)?@(?<ref>[a-z2-9]{4,12}))?$/;
82
103
 
83
104
  export function parseMessage(raw) {
84
105
  const lines = fromSlackText(String(raw ?? '').replace(/\r\n/g, '\n')).trim().split('\n');
@@ -133,10 +154,12 @@ export function addressee(item, myNickname) {
133
154
  }
134
155
 
135
156
  const text = String(item.text).toLowerCase();
136
- const tag = `@${myNickname.toLowerCase()}`;
137
- for (let at = text.indexOf(tag); at !== -1; at = text.indexOf(tag, at + 1)) {
138
- const after = text[at + tag.length];
139
- if (after === undefined || !CONTINUES.test(after)) return 'you';
157
+ for (const prefix of ['@', '*']) {
158
+ const tag = `${prefix}${myNickname.toLowerCase()}`;
159
+ for (let at = text.indexOf(tag); at !== -1; at = text.indexOf(tag, at + 1)) {
160
+ const after = text[at + tag.length];
161
+ if (after === undefined || !CONTINUES.test(after)) return 'you';
162
+ }
140
163
  }
141
164
  return 'nobody';
142
165
  }
package/src/slack.mjs CHANGED
@@ -317,6 +317,11 @@ export async function pollChannel(client, channel, { since, myNickname }) {
317
317
  const history = await client.form('conversations.history', {
318
318
  channel: channel.id,
319
319
  limit: PAGE_LIMIT,
320
+ // Without this Slack returns no metadata at all, so every message
321
+ // another agent signed arrives looking like a bare bot post and is
322
+ // dropped two lines below. Humans were unaffected, which is why the
323
+ // log looked healthy while no peer was ever seen.
324
+ include_all_metadata: true,
320
325
  ...(since ? { oldest: since } : {}),
321
326
  ...(cursor ? { cursor } : {}),
322
327
  });