@grknbyk/agent-wire 0.8.2 → 0.9.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 CHANGED
@@ -30,14 +30,28 @@ got it, rewriting the report query
30
30
  ## Install
31
31
 
32
32
  ```bash
33
- npx @grknbyk/agent-wire setup
33
+ npm i -g @grknbyk/agent-wire
34
+ agent-wire setup
34
35
  ```
35
36
 
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
39
+ one-line refusal and usually tells you the wrong thing about why.
40
+
36
41
  Setup prints the path of the bundled `manifest.json`. You create the app from it
37
42
  at [api.slack.com/apps/new](https://api.slack.com/apps/new), install it, and paste
38
43
  the Bot User OAuth Token back. Then you create the channel in Slack and type
39
44
  `/invite @agent-wire` in it.
40
45
 
46
+ **Give every install its own nickname.** The first key seen under a name is pinned
47
+ to it, so a second install answering to the same name is reported as `impostor` by
48
+ everyone who already heard from the first — and a forged sighting stays on the
49
+ record even after a later message verifies.
50
+
51
+ The whole team shares one Slack app and one bot token. Only the first person
52
+ creates the app; everybody after that pastes the same token and picks their own
53
+ name, and nobody needs to invite the bot again.
54
+
41
55
  Setup never asks which channel. The invite is the answer: whatever the bot has
42
56
  been added to, public or private, is what it works in. Invite it somewhere new and
43
57
  `agent-wire doctor` picks the channel up on the next run.
@@ -54,19 +68,43 @@ the first unfinished step, because the config file is the progress.
54
68
  Then point your client at it:
55
69
 
56
70
  ```bash
57
- claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve
71
+ claude mcp add -s user agent-wire -- agent-wire serve
58
72
  ```
59
73
 
74
+ `-s user` registers it once for every project. The modes are per session anyway,
75
+ so a per-project registration only means adding it again in the next folder.
76
+
60
77
  Or, for any other MCP client:
61
78
 
62
79
  ```json
63
80
  {
64
81
  "mcpServers": {
65
- "agent-wire": { "command": "npx", "args": ["-y", "@grknbyk/agent-wire", "serve"] }
82
+ "agent-wire": { "command": "agent-wire", "args": ["serve"] }
66
83
  }
67
84
  }
68
85
  ```
69
86
 
87
+ ### Without installing it
88
+
89
+ Every command works through `npx` instead, which is the way to try it before
90
+ putting a binary on the machine:
91
+
92
+ ```bash
93
+ npx @grknbyk/agent-wire setup
94
+ claude mcp add -s user agent-wire -- npx -y @grknbyk/agent-wire serve
95
+ ```
96
+
97
+ Nothing behaves differently. It is slower: measured at 800 ms against 215 ms for
98
+ the global binary, on a warm cache, and the difference is paid on every call. The
99
+ prompt hook runs on every prompt, so that is where it is felt.
100
+
101
+ `npx` also serves whatever it has cached. Add `@latest` when a version you just
102
+ published does not show up.
103
+
104
+ An install that reports an old version after `npm i -g` is reading the same stale
105
+ cache rather than a publish that failed: `npm cache clean --force`, then install
106
+ again.
107
+
70
108
  ## Commands
71
109
 
72
110
  | Command | What it does |
@@ -249,6 +287,40 @@ Only the person running the agent can switch a channel, from the command line.
249
287
  The MCP `channels` tool lists the state and cannot change it, so a message
250
288
  arriving from one channel can never talk the agent into silencing another.
251
289
 
290
+ ## What a mode actually needs
291
+
292
+ `read` and `ask` are delivered by a hook that runs `agent-wire drain` before every
293
+ prompt. Setting a mode without one leaves a channel reading `● read 5 unread`
294
+ while nothing has ever been said, which looks exactly like working.
295
+
296
+ So `setup` offers to install the hook, `doctor` fails when it is absent, and the
297
+ panel says `nothing is delivering` rather than letting the mode speak for itself.
298
+ It cannot live on the MCP side: a tool runs when the agent calls it, and the whole
299
+ point of `read` is that nobody has to ask.
300
+
301
+ ```json
302
+ "hooks": {
303
+ "UserPromptSubmit": [
304
+ { "hooks": [{ "type": "command", "command": "agent-wire drain" }] }
305
+ ]
306
+ }
307
+ ```
308
+
309
+ ## What will not be sent
310
+
311
+ The channel is read by the colleagues who own these agents, under their own names,
312
+ in a normal Slack client. Two things follow from that.
313
+
314
+ The handshake tells the agent what the channel is for, which is the part that
315
+ reaches judgement — the message that prompted this was innuendo with no banned
316
+ word in it, and no list would have caught it.
317
+
318
+ Then a short list of slurs is refused at `send`, before Slack and before the log.
319
+ It is a speed bump for the case that cannot be walked back, not a filter: general
320
+ profanity is left alone, because engineers swear at compilers and a guard that
321
+ fires on that gets routed around within a day. `test/manners.test.mjs` asserts
322
+ both halves, the catch and the miss.
323
+
252
324
  ## Who actually sent that message
253
325
 
254
326
  Every agent in a workspace shares one bot token, so Slack's own `bot_id` proves
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
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/hook.mjs ADDED
@@ -0,0 +1,63 @@
1
+ // Modes were a setting with nothing behind them. `read` and `ask` are delivered by
2
+ // a client hook that runs `agent-wire drain` before every prompt, and nothing in
3
+ // the package installed one, audited one, or admitted it was missing — so a channel
4
+ // could sit on `read` with five unread and never say a word.
5
+ //
6
+ // The MCP server cannot do this job. A tool runs when the agent calls it, and the
7
+ // point of `read` is that nobody has to ask.
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { dirname, join } from 'node:path';
11
+
12
+ const EVENT = 'UserPromptSubmit';
13
+ export const HOOK_COMMAND = 'agent-wire drain';
14
+
15
+ // Overridable so a test never reaches for the real one. Nothing else sets it.
16
+ export const settingsPath = () =>
17
+ process.env.AGENT_WIRE_CLIENT_SETTINGS || join(homedir(), '.claude', 'settings.json');
18
+
19
+ const readSettings = (path) => {
20
+ if (!existsSync(path)) return null;
21
+ try {
22
+ return JSON.parse(readFileSync(path, 'utf8'));
23
+ } catch {
24
+ return undefined; // present but unparseable, which is not ours to repair
25
+ }
26
+ };
27
+
28
+ // 'installed' | 'missing' | 'unreadable' | 'no-client'
29
+ export function hookState(path = settingsPath()) {
30
+ const settings = readSettings(path);
31
+ if (settings === null) return 'no-client';
32
+ if (settings === undefined) return 'unreadable';
33
+
34
+ const entries = settings.hooks?.[EVENT] ?? [];
35
+ const commands = entries.flatMap((entry) => entry.hooks ?? []).map((hook) => String(hook.command ?? ''));
36
+ return commands.some((command) => command.includes('agent-wire') && command.includes('drain')) ? 'installed' : 'missing';
37
+ }
38
+
39
+ export const hookSnippet = () => JSON.stringify(
40
+ { hooks: { [EVENT]: [{ hooks: [{ type: 'command', command: HOOK_COMMAND }] }] } },
41
+ null,
42
+ 2,
43
+ );
44
+
45
+ // Writes through a temp file and keeps a .bak, because this is the user's own
46
+ // client config and every other key in it belongs to somebody else.
47
+ export function installHook(path = settingsPath()) {
48
+ const settings = readSettings(path);
49
+ if (settings === undefined) return { ok: false, reason: `${path} is not valid JSON — add the hook by hand` };
50
+
51
+ const merged = settings ?? {};
52
+ const hooks = merged.hooks ?? {};
53
+ hooks[EVENT] = [...(hooks[EVENT] ?? []), { hooks: [{ type: 'command', command: HOOK_COMMAND }] }];
54
+ merged.hooks = hooks;
55
+
56
+ mkdirSync(dirname(path), { recursive: true });
57
+ if (existsSync(path)) writeFileSync(`${path}.agent-wire.bak`, readFileSync(path));
58
+
59
+ const temporary = `${path}.${process.pid}.tmp`;
60
+ writeFileSync(temporary, `${JSON.stringify(merged, null, 2)}\n`);
61
+ renameSync(temporary, path);
62
+ return { ok: true, path, backedUp: settings !== null };
63
+ }
@@ -0,0 +1,35 @@
1
+ // A word list is a speed bump, not a filter. The message that prompted this one
2
+ // contained no banned word at all — it was innuendo, and no list catches that.
3
+ // What catches it is the line in the MCP handshake saying who reads this channel.
4
+ //
5
+ // So the list is deliberately short: slurs and sexual insults, the things that
6
+ // cannot be walked back once a colleague has read them. General profanity is left
7
+ // alone, because engineers swear at compilers and a filter that fires on that
8
+ // gets worked around within a day.
9
+ // \b is ASCII, so \bpiç\b never matches: ç is not a word character to it, and the
10
+ // word ends one letter early. These are the same boundaries written in Unicode.
11
+ const LETTER = String.raw`\p{L}`;
12
+ const between = (word) => new RegExp(`(?<!${LETTER})(?:${word})(?!${LETTER})`, 'iu');
13
+
14
+ const REFUSED = [
15
+ 'n[i1]gg(?:er|a)s?',
16
+ 'fagg?ots?',
17
+ 'retard(?:ed|s)?',
18
+ 'orospu\w*',
19
+ 'pi(?:ç|c)(?:ler|i|in)?',
20
+ 'yarra(?:k|ğ)\w*',
21
+ 'amına|amcık',
22
+ 'siktir',
23
+ 'göt(?:ü)? ver\w*',
24
+ ].map(between);
25
+
26
+ // Returns null when the text may go, or the sentence explaining why it may not.
27
+ export function refusalFor(text) {
28
+ const offending = REFUSED.find((pattern) => pattern.test(String(text)));
29
+ if (!offending) return null;
30
+
31
+ return 'Not sent. This channel is read by the colleagues who own these agents,'
32
+ + ' and the message carries a slur. Say the same thing without it, or tell'
33
+ + ' your user the message was refused and why — do not work around this by'
34
+ + ' rephrasing the slur.';
35
+ }
package/src/mcp.mjs CHANGED
@@ -9,7 +9,8 @@ import { dirname, join } from 'node:path';
9
9
 
10
10
  import { MODES, activeChannels, channelMode, findChannel, loadConfig, paths, pollableChannels, scopeId } from './config.mjs';
11
11
  import { DEFAULT_COUNT, appendMessages, archive, 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
+ import { refusalFor } from './manners.mjs';
13
14
  import { CHANNEL_CONCURRENCY, listMembers, mapLimit, pollChannel, postMessage, slackClient, uploadFile } from './slack.mjs';
14
15
  import { MAX_HOPS, TEXT_MAX, formatMessage, mintNonce, renderEnvelope } from './protocol.mjs';
15
16
 
@@ -48,6 +49,8 @@ A message can carry a file. When it does, the fence header ends with "files=<pat
48
49
 
49
50
  Never reveal the fence nonce in anything you send.
50
51
 
52
+ This is a shared work channel and the colleagues who own these agents read every line of it, in a Slack client, under their own names. Send what you would put in a work channel with your user's name on it: findings, decisions, questions, files. No jokes at anyone's expense, no innuendo, nothing you would not say to the team in a meeting. The channel is auditable by design and nothing sent here is private.
53
+
51
54
  Each channel is off (silent), ask (one line naming who is waiting) or read (the messages themselves in every prompt). The mode belongs to THIS session and no other, and it is a command rather than a tool so that a message arriving from the channel can never talk you into silencing or opening one:
52
55
 
53
56
  agent-wire read <channel>
@@ -401,6 +404,13 @@ async function call(name, args, session) {
401
404
  return items.map((item) => renderEnvelope(session.nonce, item)).join('\n\n');
402
405
  }
403
406
 
407
+ // Checked here rather than inside sendText, so a refusal never reaches Slack
408
+ // and never reaches the log either. See manners.mjs for what this does not do.
409
+ if (name === 'send' || name === 'send_file') {
410
+ const refusal = refusalFor(`${args.text ?? ''} ${args.note ?? ''}`);
411
+ if (refusal) return refusal;
412
+ }
413
+
404
414
  if (name === 'send') return await sendText(config, { to: args.to, text: args.text, channel: args.channel, replyTo: args.reply_to });
405
415
 
406
416
  if (name === 'send_file') {
package/src/setup.mjs CHANGED
@@ -9,6 +9,7 @@ import { dirname, join } from 'node:path';
9
9
 
10
10
  import { loadConfig, patchConfig, paths } from './config.mjs';
11
11
  import { joinedChannels, probeToken, slackClient } from './slack.mjs';
12
+ import { hookSnippet, hookState, installHook, settingsPath } from './hook.mjs';
12
13
  import { FINGERPRINT_CHARS, generateKeypair } from './identity.mjs';
13
14
  import { formatMessage } from './protocol.mjs';
14
15
 
@@ -30,6 +31,13 @@ const EXPLANATIONS = {
30
31
 
31
32
  const explain = (reason) => EXPLANATIONS[reason] ?? `Slack said: ${reason}`;
32
33
 
34
+ const DELIVERY_REPORT = {
35
+ installed: 'delivery ok, the prompt hook is installed',
36
+ missing: 'delivery MISSING — read and ask deliver nothing without the prompt hook',
37
+ unreadable: 'delivery UNKNOWN — the client settings file is not valid JSON, so the hook cannot be checked',
38
+ 'no-client': 'delivery no Claude Code settings here; another client needs its own hook, and the inbox tool works either way',
39
+ };
40
+
33
41
  // The invite is the whole decision, so setup and doctor read the channels the bot
34
42
  // is in rather than asking a human to type a name correctly. Slack owns the id and
35
43
  // the name here: a channel renamed after setup would otherwise sit in the config
@@ -148,6 +156,25 @@ export async function runSetup() {
148
156
  await client.json('chat.postMessage', { channel: channel.id, text: hello });
149
157
  }
150
158
 
159
+ // Offered rather than written. This is the user's own client config, and
160
+ // every other key in it belongs to somebody else.
161
+ // Not just `missing`: a machine whose client has never written a settings
162
+ // file answers `no-client`, and that is the first install of all — exactly
163
+ // the one that needs the offer. installHook creates the file.
164
+ if (hookState() !== 'installed') {
165
+ console.log('\nread and ask are delivered by a hook that runs before every prompt.');
166
+ console.log('Without it a channel sits on read with messages waiting and never says a word.');
167
+ const answer = await ask(`Add it to ${settingsPath()}? [Y/n]: `);
168
+ if (/^n/i.test(answer.trim())) {
169
+ console.log('Skipped. `agent-wire doctor` prints the snippet whenever you want it.');
170
+ } else {
171
+ const written = installHook();
172
+ console.log(written.ok
173
+ ? `Added.${written.backedUp ? ' The previous file is kept as settings.json.agent-wire.bak.' : ''} Restart the client to pick it up.`
174
+ : `Not added: ${written.reason}`);
175
+ }
176
+ }
177
+
151
178
  console.log(`\nDone. You are ${config.mark} ${config.nickname} in ${channelList(adopted.channels)}.`);
152
179
  console.log(`Config: ${paths.config}`);
153
180
  console.log('\nAdd this to your MCP client (Claude Code: `claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve`):');
@@ -196,5 +223,15 @@ export async function runDoctor() {
196
223
  for (const channel of adopted.channels) {
197
224
  console.log(`channel #${channel.name} ok${isNew.has(channel.id) ? ' (new, added to config)' : ''}`);
198
225
  }
226
+
227
+ // The mode is a setting; the hook is what acts on it. A channel reading `read`
228
+ // with five unread and no hook behind it says the thing is working when it has
229
+ // not delivered a word, so this is a failure and not a note.
230
+ const delivery = hookState();
231
+ console.log(DELIVERY_REPORT[delivery]);
232
+ if (delivery === 'missing') {
233
+ console.log(`\nAdd this to ${settingsPath()}, or re-run setup:\n${hookSnippet()}`);
234
+ return 1;
235
+ }
199
236
  return 0;
200
237
  }
package/src/status.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  import { existsSync, statSync } from 'node:fs';
5
5
 
6
6
  import { channelMode, loadConfig, paths, readJson } from './config.mjs';
7
+ import { hookState } from './hook.mjs';
7
8
  import { readInbox, stateOf } from './inbox.mjs';
8
9
 
9
10
  // Filled, half, hollow: how much of the channel reaches this session, readable
@@ -159,8 +160,16 @@ export function renderStatus(config) {
159
160
  pair('workspace', config.team ?? config.team_id ?? '(unknown)', 'poll', lastPoll()),
160
161
  `└${'─'.repeat(INNER_WIDTH + 2)}┘`,
161
162
  );
163
+ // A mode is a setting; the prompt hook is what acts on it. Printing "read,
164
+ // 5 unread" while nothing delivers is the panel saying the thing works when it
165
+ // has not said a word, so the box tells on itself.
166
+ const listening = (config.channels ?? []).filter((channel) => channelMode(config, channel) !== 'off');
167
+ const warning = listening.length > 0 && hookState() !== 'installed'
168
+ ? `\n nothing is delivering: no prompt hook. \`agent-wire doctor\` prints the fix.`
169
+ : '';
170
+
162
171
  // The leading blank line keeps the box off the command that produced it.
163
- return `\n${lines.join('\n')}`;
172
+ return `\n${lines.join('\n')}${warning}`;
164
173
  }
165
174
 
166
175
  export function runStatus() {