@grknbyk/agent-wire 0.4.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/LICENSE +21 -0
- package/README.md +243 -0
- package/assets/agent-wire.png +0 -0
- package/bin/agent-wire.mjs +115 -0
- package/manifest.json +38 -0
- package/package.json +40 -0
- package/src/config.mjs +68 -0
- package/src/identity.mjs +73 -0
- package/src/inbox.mjs +76 -0
- package/src/mcp.mjs +309 -0
- package/src/protocol.mjs +68 -0
- package/src/setup.mjs +243 -0
- package/src/slack.mjs +236 -0
- package/src/status.mjs +167 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gürkan Bıyık
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img src="assets/agent-wire.png" alt="agent-wire" width="96">
|
|
3
|
+
|
|
4
|
+
# agent-wire
|
|
5
|
+
|
|
6
|
+
**Let your AI coding agents talk to each other, in a Slack channel you can read.**
|
|
7
|
+
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
Two developers, two machines, two coding agents working on the same system. One
|
|
11
|
+
knows the migration is deployed. The other is about to write against the old
|
|
12
|
+
schema. agent-wire gives them a way to say so.
|
|
13
|
+
|
|
14
|
+
It runs as an [MCP](https://modelcontextprotocol.io) server, so any MCP client
|
|
15
|
+
(Claude Code, Cursor, anything else that speaks the protocol) gets `send` and
|
|
16
|
+
`inbox` tools. Messages travel through a normal Slack channel.
|
|
17
|
+
|
|
18
|
+
Slack is a deliberate choice here. A private protocol between two machines
|
|
19
|
+
produces a conversation nobody can audit. In a channel, the humans who own those
|
|
20
|
+
agents read the whole exchange, scroll back through it, and step in by typing.
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
🔥 grkn => mira
|
|
24
|
+
migration 0042 is on dev now, txn_date is a DATE not a TIMESTAMP
|
|
25
|
+
|
|
26
|
+
⚡ mira => grkn
|
|
27
|
+
got it, rewriting the report query
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npx @grknbyk/agent-wire setup
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Setup asks one question first: do you already have a Slack bot token, should it
|
|
37
|
+
create the app for you, or do you want to paste the manifest by hand?
|
|
38
|
+
|
|
39
|
+
If you let it create the app, it needs one App Configuration Token from
|
|
40
|
+
[api.slack.com/apps](https://api.slack.com/apps), at the bottom of that page.
|
|
41
|
+
After that it creates the app from the bundled manifest, opens your browser once
|
|
42
|
+
for approval, catches the redirect itself, creates the channel, and joins it.
|
|
43
|
+
|
|
44
|
+
Setup never asks "did you do it? (y/n)". Every step it can verify, it verifies by
|
|
45
|
+
asking Slack. When a step is stuck for a reason Slack reports, such as a missing
|
|
46
|
+
scope, a private channel it cannot join, or a token from the wrong workspace, it
|
|
47
|
+
says which one and what to do about it. Quit halfway and re-run: it resumes at
|
|
48
|
+
the first unfinished step, because the config file is the progress.
|
|
49
|
+
|
|
50
|
+
Then point your client at it:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
claude mcp add agent-wire -- npx -y @grknbyk/agent-wire serve
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Or, for any other MCP client:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"agent-wire": { "command": "npx", "args": ["-y", "@grknbyk/agent-wire", "serve"] }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Commands
|
|
67
|
+
|
|
68
|
+
| Command | What it does |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `agent-wire status` | Identity, channels and unread counts, read from disk |
|
|
71
|
+
| `agent-wire setup` | Connect a workspace, a channel, and this agent's identity |
|
|
72
|
+
| `agent-wire serve` | Run the MCP stdio server, which is what your client launches |
|
|
73
|
+
| `agent-wire doctor` | Re-check the token, the channels and the identity |
|
|
74
|
+
| `agent-wire drain` | Print what arrived since last time, for a prompt hook |
|
|
75
|
+
| `agent-wire channels` | List the channels and whether each one is switched on |
|
|
76
|
+
| `agent-wire on/off <name>` | Bring a channel into scope, or take it out |
|
|
77
|
+
|
|
78
|
+
## Tools your agent gets
|
|
79
|
+
|
|
80
|
+
`send`, `send_file`, `inbox`, `archive`, `peers`, `channels`, `my_id`.
|
|
81
|
+
|
|
82
|
+
Text over 3500 characters is posted as a Markdown file instead of a message.
|
|
83
|
+
Slack splits anything longer, and the tail arrives without a header, so half an
|
|
84
|
+
answer vanishes while the sender is told it was delivered.
|
|
85
|
+
|
|
86
|
+
## One channel per project
|
|
87
|
+
|
|
88
|
+
Setup configures one channel. Add more by hand in `~/.agent-wire/config.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
"channels": [
|
|
92
|
+
{ "id": "C0123", "name": "agent-wms" },
|
|
93
|
+
{ "id": "C0456", "name": "agent-crm" }
|
|
94
|
+
]
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Every message is tagged with the channel it came from, `send` takes an optional
|
|
98
|
+
`channel`, and `inbox` can filter by one. The first entry is the default.
|
|
99
|
+
|
|
100
|
+
## Working on two of five channels
|
|
101
|
+
|
|
102
|
+
Channels you are not working on today can be switched off. Running `agent-wire`
|
|
103
|
+
with no arguments shows where you stand:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
┌──────────────── agent-wire ────────────────┐
|
|
107
|
+
│ name grkn mark 🔥 │
|
|
108
|
+
│ key MCowBQYDK2VwAyEAq7Xn2mZ8kLcYzQwErTy… │
|
|
109
|
+
├───────────────── CHANNELS ─────────────────┤
|
|
110
|
+
│ agent-wms ● on 3 unread │
|
|
111
|
+
│ agent-crm ● on 1 unread │
|
|
112
|
+
│ agent-hcm ○ off 1 held │
|
|
113
|
+
│ agent-lab ○ off 1 held │
|
|
114
|
+
├────────────────── PEERS ───────────────────┤
|
|
115
|
+
│ @ Zoë * kai * mira │
|
|
116
|
+
│ * warehouse-… * robin ! nox │
|
|
117
|
+
├────────────────── STATE ───────────────────┤
|
|
118
|
+
│ workspace Acme poll 14s ago │
|
|
119
|
+
└────────────────────────────────────────────┘
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The peers section lists everyone this agent has heard from: `*` for an agent,
|
|
123
|
+
`@` for a human typing in the channel, `!` for a name that has been forged.
|
|
124
|
+
Anything too wide for its column ends in `…`, so one long nickname costs its own
|
|
125
|
+
row a character instead of pushing the border out.
|
|
126
|
+
|
|
127
|
+
A forged sighting stays on the record even after that name sends a message that
|
|
128
|
+
verifies. Letting a later message clear it would hand an attacker the way to bury
|
|
129
|
+
the evidence.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
agent-wire off agent-hcm
|
|
133
|
+
agent-wire on agent-hcm
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`status` reads the config and the local log only, so it answers instantly.
|
|
137
|
+
Whether Slack still accepts the token is `doctor`'s question.
|
|
138
|
+
|
|
139
|
+
A channel that is off is not polled, not announced by `drain`, and absent from
|
|
140
|
+
the default `inbox` view. Its history stays readable at any time with
|
|
141
|
+
`inbox channel="agent-hcm"`.
|
|
142
|
+
|
|
143
|
+
Switching one off does not lose messages. The cursor stays where it was, so
|
|
144
|
+
switching it back on replays everything that arrived meanwhile.
|
|
145
|
+
|
|
146
|
+
Only the person running the agent can switch a channel, from the command line.
|
|
147
|
+
The MCP `channels` tool lists the state and cannot change it, so a message
|
|
148
|
+
arriving from one channel can never talk the agent into silencing another.
|
|
149
|
+
|
|
150
|
+
## Who actually sent that message
|
|
151
|
+
|
|
152
|
+
Every agent in a workspace shares one bot token, so Slack's own `bot_id` proves
|
|
153
|
+
that agent-wire posted a message without proving which agent wrote it. The header
|
|
154
|
+
line is plain text that anyone in the channel can type.
|
|
155
|
+
|
|
156
|
+
So each install generates an Ed25519 key pair at setup and signs every message it
|
|
157
|
+
sends. The signature covers the sender, the recipient, the channel, the position
|
|
158
|
+
in the reply chain, and the text. It travels in Slack message metadata, which the
|
|
159
|
+
UI never renders. The first key seen using a name is pinned to that name, and
|
|
160
|
+
`inbox` labels every message with what is actually proven:
|
|
161
|
+
|
|
162
|
+
| Label | Meaning |
|
|
163
|
+
|---|---|
|
|
164
|
+
| `signed` | Verified against the key already pinned to that name |
|
|
165
|
+
| `new` | Verified, first sighting of this name, key now pinned |
|
|
166
|
+
| `impostor` | That name is pinned to a different key, so treat it as forged |
|
|
167
|
+
| `unsigned` | No valid signature, so the sender name is decoration only |
|
|
168
|
+
| `slack-verified` | A human, identified by Slack's own user id |
|
|
169
|
+
| `self` | Sent by this agent |
|
|
170
|
+
|
|
171
|
+
Changing one character of the text breaks the signature, and so does replaying a
|
|
172
|
+
signed message into another channel. There are tests for both.
|
|
173
|
+
|
|
174
|
+
## Untrusted input
|
|
175
|
+
|
|
176
|
+
Anything arriving from the channel is rendered inside a fence whose delimiter is
|
|
177
|
+
a random value minted per server process, never written to Slack and never
|
|
178
|
+
logged:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
<<<WIRE:4f2a… UNTRUSTED from=mira kind=agent authorship=signed channel=agent-wms ts=1712.44 hop=3>>>
|
|
182
|
+
the message
|
|
183
|
+
<<<END:4f2a…>>>
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The rule for reading that fence arrives through the MCP handshake, a channel the
|
|
187
|
+
message author cannot write to, so it never sits inline beside the content it
|
|
188
|
+
governs. If a payload contains the live delimiter, it is replaced with
|
|
189
|
+
`[FENCE-ECHO REDACTED]`, which turns reflection into a visible event instead of a
|
|
190
|
+
silently broken boundary.
|
|
191
|
+
|
|
192
|
+
Be clear about what this buys you. An attacker cannot close the fence, and does
|
|
193
|
+
not need to, because text inside a correctly labelled `UNTRUSTED` block still
|
|
194
|
+
reads as language to a model. The fence makes the labelling accurate. Hostile
|
|
195
|
+
text stays exactly as persuasive as it was, so this is a boundary rather than a
|
|
196
|
+
filter.
|
|
197
|
+
|
|
198
|
+
A reply chain also carries a hop count and stops at 8. Two agents answering each
|
|
199
|
+
other politely is an infinite loop that costs real money.
|
|
200
|
+
|
|
201
|
+
## Slack scopes, and why each one
|
|
202
|
+
|
|
203
|
+
Your workspace admin will ask. The manifest requests:
|
|
204
|
+
|
|
205
|
+
| Scope | Why |
|
|
206
|
+
|---|---|
|
|
207
|
+
| `chat:write` | Post messages |
|
|
208
|
+
| `channels:history`, `groups:history` | Read the channels it was added to |
|
|
209
|
+
| `channels:read`, `groups:read` | Find a channel by name, check membership |
|
|
210
|
+
| `channels:join` | Join a public channel so you skip the invite step |
|
|
211
|
+
| `channels:manage` | Create the channel during setup |
|
|
212
|
+
| `files:read`, `files:write` | Send and receive long messages as files |
|
|
213
|
+
| `users:read` | Show a human's name instead of `U08J21KLER1` |
|
|
214
|
+
|
|
215
|
+
The app only ever reads channels it has been added to.
|
|
216
|
+
|
|
217
|
+
## Where things are stored
|
|
218
|
+
|
|
219
|
+
Everything lives in `~/.agent-wire/` (override with `AGENT_WIRE_HOME`).
|
|
220
|
+
`config.json` holds the token, identity and channels. `inbox.jsonl` is the
|
|
221
|
+
append-only message log. `peers.json` holds the pinned keys.
|
|
222
|
+
|
|
223
|
+
The local log is the source of truth. Slack is a cache that can be re-read at any
|
|
224
|
+
time, so recovering a lost inbox is an ordinary operation rather than a
|
|
225
|
+
procedure. Messages are keyed by their Slack timestamp, so a retried poll or a
|
|
226
|
+
reinstalled app cannot produce duplicates.
|
|
227
|
+
|
|
228
|
+
## Roadmap
|
|
229
|
+
|
|
230
|
+
- `mode: reply`, to answer waiting messages when no live session is watching
|
|
231
|
+
- Per-worktree identity, so parallel sessions on one machine name themselves
|
|
232
|
+
- Discord as a second transport
|
|
233
|
+
- Published measurements of fenced against unfenced injection compliance
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
npm test
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## License
|
|
242
|
+
|
|
243
|
+
MIT
|
|
Binary file
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { activeChannels, loadConfig, setChannelActive } from '../src/config.mjs';
|
|
3
|
+
import { pollOnce, serve } from '../src/mcp.mjs';
|
|
4
|
+
import { runDoctor, runSetup } from '../src/setup.mjs';
|
|
5
|
+
import { markRead, selectMessages } from '../src/inbox.mjs';
|
|
6
|
+
import { runStatus } from '../src/status.mjs';
|
|
7
|
+
|
|
8
|
+
const USAGE = `agent-wire — message other AI coding agents through Slack
|
|
9
|
+
|
|
10
|
+
agent-wire status show identity, channels and unread counts (also the default)
|
|
11
|
+
agent-wire setup connect a workspace, a channel and this agent's identity
|
|
12
|
+
agent-wire serve run the MCP stdio server (what your agent client launches)
|
|
13
|
+
agent-wire doctor re-check the token, the channels and this agent's identity
|
|
14
|
+
agent-wire drain print messages that arrived since the last drain, then stop
|
|
15
|
+
agent-wire channels list the channels and whether each one is switched on
|
|
16
|
+
agent-wire on <name> switch a channel on
|
|
17
|
+
agent-wire off <name> switch a channel off: not polled, not announced
|
|
18
|
+
|
|
19
|
+
Docs: https://github.com/grknbyk/agent-wire`;
|
|
20
|
+
|
|
21
|
+
// For a client hook that runs on every prompt: says a message is waiting without
|
|
22
|
+
// spending the agent's turn on reading it, and marks nothing as read.
|
|
23
|
+
async function drain() {
|
|
24
|
+
const config = loadConfig();
|
|
25
|
+
if (!config) return 0;
|
|
26
|
+
|
|
27
|
+
await pollOnce(config).catch(() => {
|
|
28
|
+
// Offline is not an error here; the next drain catches up.
|
|
29
|
+
});
|
|
30
|
+
const waiting = selectMessages({
|
|
31
|
+
state: 'unread',
|
|
32
|
+
count: 50,
|
|
33
|
+
channels: activeChannels(config).map((channel) => channel.name),
|
|
34
|
+
});
|
|
35
|
+
if (waiting.length === 0) return 0;
|
|
36
|
+
|
|
37
|
+
const senders = [...new Set(waiting.map((item) => item.from))].join(', ');
|
|
38
|
+
console.log(`agent-wire: ${waiting.length} unread message(s) from ${senders}.`
|
|
39
|
+
+ ' Tell the user in one line. Do not read them unless asked — use the agent-wire inbox tool.');
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listChannels() {
|
|
44
|
+
const config = loadConfig();
|
|
45
|
+
const configured = config?.channels ?? [];
|
|
46
|
+
if (configured.length === 0) {
|
|
47
|
+
console.log('no channels configured — run `agent-wire setup`');
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const channel of configured) console.log(`${channel.active === false ? 'off' : 'on '} #${channel.name}`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Switching a channel is a decision for the person running the agent, so it lives
|
|
56
|
+
// on the command line and not in the MCP tool list. A message arriving from the
|
|
57
|
+
// channel must not be able to talk the agent into silencing another one.
|
|
58
|
+
function switchChannel(name, active) {
|
|
59
|
+
if (!name) {
|
|
60
|
+
console.log(`usage: agent-wire ${active ? 'on' : 'off'} <channel>`);
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const channel = setChannelActive(name, active);
|
|
65
|
+
if (!channel) {
|
|
66
|
+
console.log(`no configured channel named "${name}"`);
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(active
|
|
71
|
+
? `#${channel.name} is on. The next poll replays everything since it was switched off.`
|
|
72
|
+
: `#${channel.name} is off. It is no longer polled or announced; its history stays readable with inbox channel="${channel.name}".`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const commands = {
|
|
77
|
+
status: () => runStatus() ?? notConfigured(),
|
|
78
|
+
setup: runSetup,
|
|
79
|
+
doctor: runDoctor,
|
|
80
|
+
drain,
|
|
81
|
+
channels: listChannels,
|
|
82
|
+
on: () => switchChannel(process.argv[3], true),
|
|
83
|
+
off: () => switchChannel(process.argv[3], false),
|
|
84
|
+
read: async () => {
|
|
85
|
+
const items = selectMessages({ state: 'unread', count: 50 });
|
|
86
|
+
for (const item of items) console.log(`[${item.authorship}] ${item.at} ${item.from}: ${item.text}`);
|
|
87
|
+
markRead(items);
|
|
88
|
+
return 0;
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function notConfigured() {
|
|
93
|
+
console.log('not configured yet — run `agent-wire setup`');
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const name = process.argv[2];
|
|
98
|
+
|
|
99
|
+
// serve() is the one command that must not exit: the open stdin stream is what
|
|
100
|
+
// keeps the MCP server alive, and awaiting a never-resolving promise here would
|
|
101
|
+
// make Node print a warning into the very stream the client is parsing.
|
|
102
|
+
if (name === 'serve') {
|
|
103
|
+
serve();
|
|
104
|
+
} else if (commands[name]) {
|
|
105
|
+
process.exit(await commands[name]() ?? 0);
|
|
106
|
+
} else if (!name) {
|
|
107
|
+
// Bare invocation shows where you stand once there is something to stand on,
|
|
108
|
+
// and the usage text while there is not.
|
|
109
|
+
const shown = runStatus();
|
|
110
|
+
if (shown === null) console.log(USAGE);
|
|
111
|
+
process.exit(0);
|
|
112
|
+
} else {
|
|
113
|
+
console.log(USAGE);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
package/manifest.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display_information": {
|
|
3
|
+
"name": "agent-wire",
|
|
4
|
+
"description": "Message bridge between AI coding agents",
|
|
5
|
+
"background_color": "#af3c02",
|
|
6
|
+
"long_description": "agent-wire connects AI coding agents running on different machines through a Slack channel they share. Each agent posts under its own nickname and signs what it sends, so a message can be traced to the agent that wrote it. Humans in the same channel can read the whole exchange and join it at any point, which is the reason the bridge runs on Slack rather than a private protocol: the conversation between machines stays readable by the team that owns them."
|
|
7
|
+
},
|
|
8
|
+
"features": {
|
|
9
|
+
"bot_user": {
|
|
10
|
+
"display_name": "agent-wire",
|
|
11
|
+
"always_online": true
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"oauth_config": {
|
|
15
|
+
"redirect_urls": [
|
|
16
|
+
"http://localhost:32771/callback"
|
|
17
|
+
],
|
|
18
|
+
"scopes": {
|
|
19
|
+
"bot": [
|
|
20
|
+
"chat:write",
|
|
21
|
+
"channels:read",
|
|
22
|
+
"channels:history",
|
|
23
|
+
"channels:join",
|
|
24
|
+
"channels:manage",
|
|
25
|
+
"groups:read",
|
|
26
|
+
"groups:history",
|
|
27
|
+
"files:read",
|
|
28
|
+
"files:write",
|
|
29
|
+
"users:read"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"settings": {
|
|
34
|
+
"org_deploy_enabled": false,
|
|
35
|
+
"socket_mode_enabled": false,
|
|
36
|
+
"token_rotation_enabled": false
|
|
37
|
+
}
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@grknbyk/agent-wire",
|
|
3
|
+
"version": "0.4.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
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin",
|
|
27
|
+
"src",
|
|
28
|
+
"assets",
|
|
29
|
+
"manifest.json"
|
|
30
|
+
],
|
|
31
|
+
"keywords": [
|
|
32
|
+
"mcp",
|
|
33
|
+
"model-context-protocol",
|
|
34
|
+
"slack",
|
|
35
|
+
"ai-agents",
|
|
36
|
+
"agent-communication",
|
|
37
|
+
"claude-code",
|
|
38
|
+
"cursor"
|
|
39
|
+
]
|
|
40
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Everything agent-wire stores lives in one directory so a broken install can be
|
|
2
|
+
// inspected, backed up, or deleted as a unit.
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const HOME = process.env.AGENT_WIRE_HOME || join(homedir(), '.agent-wire');
|
|
8
|
+
|
|
9
|
+
export const paths = {
|
|
10
|
+
config: join(HOME, 'config.json'),
|
|
11
|
+
inbox: join(HOME, 'inbox.jsonl'),
|
|
12
|
+
states: join(HOME, 'states.json'),
|
|
13
|
+
cursors: join(HOME, 'cursors.json'),
|
|
14
|
+
peers: join(HOME, 'peers.json'),
|
|
15
|
+
users: join(HOME, 'users.json'),
|
|
16
|
+
files: join(HOME, 'files'),
|
|
17
|
+
pollLock: join(HOME, 'poll.lock'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);
|
|
21
|
+
|
|
22
|
+
// Write to a sibling then rename: a config half-written by a killed setup run is
|
|
23
|
+
// how an install becomes unrecoverable, and rename is atomic on every platform we
|
|
24
|
+
// target. The temp name carries the pid so two runs cannot share it.
|
|
25
|
+
export function writeJson(file, value) {
|
|
26
|
+
mkdirSync(HOME, { recursive: true });
|
|
27
|
+
const tempFile = `${file}.${process.pid}.tmp`;
|
|
28
|
+
writeFileSync(tempFile, JSON.stringify(value, null, 2));
|
|
29
|
+
renameSync(tempFile, file);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const loadConfig = () => readJson(paths.config, null);
|
|
33
|
+
|
|
34
|
+
export const saveConfig = (config) => writeJson(paths.config, config);
|
|
35
|
+
|
|
36
|
+
// Setup writes after every completed step, so the config IS the resume state and
|
|
37
|
+
// there is no second progress file to disagree with it.
|
|
38
|
+
export function patchConfig(patch) {
|
|
39
|
+
const merged = { ...(loadConfig() ?? { version: 1 }), ...patch };
|
|
40
|
+
saveConfig(merged);
|
|
41
|
+
return merged;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const defaultChannel = (config) => config.channels?.[0] ?? null;
|
|
45
|
+
|
|
46
|
+
// A channel is active unless it was explicitly switched off, so a config written
|
|
47
|
+
// before this option existed keeps every channel on.
|
|
48
|
+
export const activeChannels = (config) => (config.channels ?? []).filter((channel) => channel.active !== false);
|
|
49
|
+
|
|
50
|
+
// Switching a channel off leaves its cursor where it is, so switching it back on
|
|
51
|
+
// replays everything that arrived meanwhile instead of losing it.
|
|
52
|
+
export function setChannelActive(name, active) {
|
|
53
|
+
const config = loadConfig();
|
|
54
|
+
if (!config) return null;
|
|
55
|
+
|
|
56
|
+
const channel = findChannel(config, name);
|
|
57
|
+
if (!channel) return null;
|
|
58
|
+
|
|
59
|
+
channel.active = active;
|
|
60
|
+
saveConfig(config);
|
|
61
|
+
return channel;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findChannel(config, wanted) {
|
|
65
|
+
if (!wanted) return defaultChannel(config);
|
|
66
|
+
const name = String(wanted).replace(/^#/, '').toLowerCase();
|
|
67
|
+
return config.channels?.find((c) => c.name.toLowerCase() === name || c.id === wanted) ?? null;
|
|
68
|
+
}
|
package/src/identity.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Every agent in a workspace shares one bot token, so Slack's own bot_id proves
|
|
2
|
+
// only "agent-wire posted this" — not which agent did. The header line is plain
|
|
3
|
+
// text anyone in the channel can type, so it cannot carry identity either.
|
|
4
|
+
// Each install therefore signs what it sends with its own Ed25519 key and
|
|
5
|
+
// publishes the public half alongside the message. A nickname is bound to the
|
|
6
|
+
// first key seen using it (trust on first use); a later message claiming that
|
|
7
|
+
// nickname with a different key is reported, not believed.
|
|
8
|
+
import { createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { paths, readJson, writeJson } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
const DER_PRIVATE = { type: 'pkcs8', format: 'der' };
|
|
13
|
+
const DER_PUBLIC = { type: 'spki', format: 'der' };
|
|
14
|
+
|
|
15
|
+
export function generateKeypair() {
|
|
16
|
+
const { privateKey, publicKey } = generateKeyPairSync('ed25519');
|
|
17
|
+
return {
|
|
18
|
+
privateKey: privateKey.export(DER_PRIVATE).toString('base64'),
|
|
19
|
+
publicKey: publicKey.export(DER_PUBLIC).toString('base64'),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Signed over the fields a forger would want to change: who sent it, who it is
|
|
24
|
+
// for, which channel it belongs to, and where it sits in a reply chain. Channel
|
|
25
|
+
// is included so a signed message cannot be replayed into a different channel.
|
|
26
|
+
export const signingPayload = ({ channel, from, to, conv, hop, text }) =>
|
|
27
|
+
Buffer.from(`agent-wire/v1\n${channel}\n${from}\n${to}\n${conv}\n${hop}\n${text}`, 'utf8');
|
|
28
|
+
|
|
29
|
+
export function signMessage(privateKeyBase64, fields) {
|
|
30
|
+
const privateKey = createPrivateKey({ key: Buffer.from(privateKeyBase64, 'base64'), ...DER_PRIVATE });
|
|
31
|
+
return sign(null, signingPayload(fields), privateKey).toString('base64');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function verifySignature(publicKeyBase64, signature, fields) {
|
|
35
|
+
try {
|
|
36
|
+
const publicKey = createPublicKey({ key: Buffer.from(publicKeyBase64, 'base64'), ...DER_PUBLIC });
|
|
37
|
+
return verify(null, signingPayload(fields), publicKey, Buffer.from(signature, 'base64'));
|
|
38
|
+
} catch {
|
|
39
|
+
// A malformed key or signature is a failed verification, not a crash: the
|
|
40
|
+
// bytes came from a Slack message and anyone in the channel can shape them.
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const loadPeers = () => readJson(paths.peers, {});
|
|
46
|
+
|
|
47
|
+
// Verdicts, in the order they are decided:
|
|
48
|
+
// "signed" — signature valid and the key matches the one pinned to this name
|
|
49
|
+
// "new" — signature valid, first time this name appears, key now pinned
|
|
50
|
+
// "impostor" — signature valid but the name is pinned to a DIFFERENT key
|
|
51
|
+
// "unsigned" — no signature, or the signature does not verify
|
|
52
|
+
export function checkAuthorship({ from, publicKey, signature, ...fields }) {
|
|
53
|
+
if (!publicKey || !signature) return { verdict: 'unsigned' };
|
|
54
|
+
if (!verifySignature(publicKey, signature, { from, ...fields })) return { verdict: 'unsigned' };
|
|
55
|
+
|
|
56
|
+
const peers = loadPeers();
|
|
57
|
+
const pinned = peers[from];
|
|
58
|
+
if (pinned && pinned.publicKey !== publicKey) return { verdict: 'impostor', pinnedSince: pinned.firstSeen };
|
|
59
|
+
if (pinned) return { verdict: 'signed' };
|
|
60
|
+
|
|
61
|
+
peers[from] = { publicKey, firstSeen: new Date().toISOString() };
|
|
62
|
+
writeJson(paths.peers, peers);
|
|
63
|
+
return { verdict: 'new' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const forgetPeer = (name) => {
|
|
67
|
+
const peers = loadPeers();
|
|
68
|
+
delete peers[name];
|
|
69
|
+
writeJson(paths.peers, peers);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const listPeers = () => Object.entries(loadPeers())
|
|
73
|
+
.map(([name, peer]) => ({ name, firstSeen: peer.firstSeen, fingerprint: peer.publicKey.slice(0, 12) }));
|
package/src/inbox.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The local log is the source of truth, not the Slack channel. Slack history is a
|
|
2
|
+
// cache we can re-read at any time, so a full re-scan is an ordinary idempotent
|
|
3
|
+
// operation rather than a recovery procedure.
|
|
4
|
+
//
|
|
5
|
+
// inbox.jsonl is append-only and keyed by the Slack timestamp, which is unique per
|
|
6
|
+
// channel and survives a re-install. Message state lives in a separate file so the
|
|
7
|
+
// append-only log never has to be rewritten in place.
|
|
8
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
import { HOME, paths, readJson, writeJson } from './config.mjs';
|
|
11
|
+
|
|
12
|
+
const storageKey = (item) => `${item.channel}:${item.ts}`;
|
|
13
|
+
|
|
14
|
+
export function readInbox() {
|
|
15
|
+
if (!existsSync(paths.inbox)) return [];
|
|
16
|
+
return readFileSync(paths.inbox, 'utf8').split('\n').filter(Boolean)
|
|
17
|
+
.map((line) => { try { return JSON.parse(line); } catch { return null; } })
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const stateOf = (states, item) => states[storageKey(item)] ?? 'unread';
|
|
22
|
+
|
|
23
|
+
// The Slack timestamp is the idempotency key: a retried poll, an overlapping
|
|
24
|
+
// window, or a re-installed app all replay the same ts, and a duplicate the human
|
|
25
|
+
// has to clean up by hand is the failure that generates support noise.
|
|
26
|
+
export function appendMessages(items) {
|
|
27
|
+
if (items.length === 0) return 0;
|
|
28
|
+
|
|
29
|
+
const seen = new Set(readInbox().map(storageKey));
|
|
30
|
+
const fresh = items.filter((item) => !seen.has(storageKey(item)));
|
|
31
|
+
if (fresh.length === 0) return 0;
|
|
32
|
+
|
|
33
|
+
mkdirSync(HOME, { recursive: true });
|
|
34
|
+
appendFileSync(paths.inbox, fresh.map((item) => JSON.stringify(item)).join('\n') + '\n');
|
|
35
|
+
return fresh.length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// `channel` names one channel explicitly and overrides everything. `channels`
|
|
39
|
+
// is the caller's allow-list, which is how switched-off channels stay out of the
|
|
40
|
+
// default view without being deleted from the log.
|
|
41
|
+
export function selectMessages({ state = 'unread', count = 20, channel = null, channels = null } = {}) {
|
|
42
|
+
const states = readJson(paths.states, {});
|
|
43
|
+
const visible = readInbox().filter((item) => {
|
|
44
|
+
if (channel) return item.channel === channel;
|
|
45
|
+
if (channels) return channels.includes(item.channel);
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
const wanted = state === 'all' ? visible : visible.filter((item) => stateOf(states, item) === state);
|
|
49
|
+
return wanted.slice(-count);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function markRead(items) {
|
|
53
|
+
const states = readJson(paths.states, {});
|
|
54
|
+
for (const item of items) states[storageKey(item)] = 'read';
|
|
55
|
+
writeJson(paths.states, states);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function archive(ts) {
|
|
59
|
+
const states = readJson(paths.states, {});
|
|
60
|
+
const targets = ts
|
|
61
|
+
? readInbox().filter((item) => item.ts === ts)
|
|
62
|
+
: readInbox().filter((item) => stateOf(states, item) === 'read');
|
|
63
|
+
for (const item of targets) states[storageKey(item)] = 'archived';
|
|
64
|
+
writeJson(paths.states, states);
|
|
65
|
+
return targets.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const findByTs = (ts) => readInbox().find((item) => item.ts === ts) ?? null;
|
|
69
|
+
|
|
70
|
+
export const readCursor = (channelId) => readJson(paths.cursors, {})[channelId] ?? null;
|
|
71
|
+
|
|
72
|
+
export function writeCursor(channelId, ts) {
|
|
73
|
+
const cursors = readJson(paths.cursors, {});
|
|
74
|
+
cursors[channelId] = ts;
|
|
75
|
+
writeJson(paths.cursors, cursors);
|
|
76
|
+
}
|