@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 +21 -22
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/drain.mjs +1 -1
- package/src/hook.mjs +3 -3
- package/src/inbox.mjs +9 -5
- package/src/mcp.mjs +5 -5
- package/src/protocol.mjs +15 -10
- package/src/setup.mjs +5 -5
- package/src/slack.mjs +3 -3
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
214
|
-
server, the prompt hook and the shell alike
|
|
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"
|
|
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
|
|
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
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
485
|
-
|
|
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 #
|
|
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
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((
|
|
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((
|
|
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
|
|
60
|
-
writeFileSync(
|
|
61
|
-
renameSync(
|
|
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
|
|
102
|
-
return
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
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 "&lt;" into "<".
|
|
23
|
-
export const toSlackText = (
|
|
23
|
+
export const toSlackText = (text) => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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 = (
|
|
28
|
+
const unlinkify = (text) => text.replace(/<((?:https?:\/\/|mailto:)[^|>]+)(\|[^>]*)?>/g, '$1');
|
|
29
29
|
|
|
30
|
-
export const fromSlackText = (
|
|
30
|
+
export const fromSlackText = (text) => unlinkify(String(text))
|
|
31
31
|
.replace(/</g, '<').replace(/>/g, '>').replace(/&/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
|
-
|
|
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 = /^(?:(
|
|
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:
|
|
90
|
-
from
|
|
91
|
-
to
|
|
92
|
-
refChannel:
|
|
93
|
-
ref:
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
new Promise((_, reject) =>
|
|
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
|
-
|
|
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
|
|
206
|
-
if (!
|
|
205
|
+
const fileInfo = await client.form('files.info', { file: fileId });
|
|
206
|
+
if (!fileInfo.ok) return [];
|
|
207
207
|
|
|
208
|
-
const saved = await downloadAttachment(client,
|
|
208
|
+
const saved = await downloadAttachment(client, fileInfo.file);
|
|
209
209
|
return saved ? [saved] : [];
|
|
210
210
|
}
|
|
211
211
|
|