@grknbyk/agent-wire 0.4.1 → 0.6.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
@@ -33,17 +33,17 @@ got it, rewriting the report query
33
33
  npx @grknbyk/agent-wire setup
34
34
  ```
35
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?
36
+ Setup prints the path of the bundled `manifest.json`. You create the app from it
37
+ at [api.slack.com/apps/new](https://api.slack.com/apps/new), install it, and paste
38
+ the Bot User OAuth Token back. Then you create the channel in Slack and type
39
+ `/invite @agent-wire` in it.
38
40
 
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.
41
+ The app never adds itself to anything. It has no scope to create a channel or to
42
+ join one, so a person decides where it can read and write.
43
43
 
44
44
  Setup never asks "did you do it? (y/n)". Every step it can verify, it verifies by
45
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
46
+ scope, a channel nobody invited it to, or a token from the wrong workspace, it
47
47
  says which one and what to do about it. Quit halfway and re-run: it resumes at
48
48
  the first unfinished step, because the config file is the progress.
49
49
 
@@ -72,17 +72,39 @@ Or, for any other MCP client:
72
72
  | `agent-wire serve` | Run the MCP stdio server, which is what your client launches |
73
73
  | `agent-wire doctor` | Re-check the token, the channels and the identity |
74
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 |
75
+ | `agent-wire channels` | List the channels and what each one is set to here |
76
+ | `agent-wire ask <name>` | Name who is waiting and how many; open nothing |
77
+ | `agent-wire read <name>` | Put the messages themselves into every prompt |
78
+ | `agent-wire off <name>` | Say nothing about this channel in this session |
77
79
 
78
80
  ## Tools your agent gets
79
81
 
80
- `send`, `send_file`, `inbox`, `archive`, `peers`, `channels`, `my_id`.
82
+ `send`, `send_file`, `inbox`, `archive`, `peers`, `members`, `channels`, `my_id`.
83
+
84
+ The mode of a channel is a command the user runs, never a tool. A message
85
+ arriving from the channel must not be able to talk the agent into silencing
86
+ another channel, nor into opening one.
81
87
 
82
88
  Text over 3500 characters is posted as a Markdown file instead of a message.
83
89
  Slack splits anything longer, and the tail arrives without a header, so half an
84
90
  answer vanishes while the sender is told it was delivered.
85
91
 
92
+ ## Files go both ways
93
+
94
+ `send_file` uploads, and the receiving side downloads. A `.md` plan sent from one
95
+ machine lands on the other as a real file in `~/.agent-wire/files/`, and `inbox`
96
+ prints that path in the fence header, so the agent opens it with its own tools.
97
+ Files a human drags into the channel arrive the same way.
98
+
99
+ Slack accepts no metadata on a file upload, so the file and the message that
100
+ describes it are two posts. The message is the signed one, and the file id it
101
+ names is inside what the signature covers, so a valid signature cannot be lifted
102
+ onto somebody else's upload. A message that fails verification is never
103
+ downloaded.
104
+
105
+ Anything over 20 MB stays in Slack. The message still arrives and says why the
106
+ file was left there.
107
+
86
108
  ## One channel per project
87
109
 
88
110
  Setup configures one channel. Add more by hand in `~/.agent-wire/config.json`:
@@ -97,18 +119,64 @@ Setup configures one channel. Add more by hand in `~/.agent-wire/config.json`:
97
119
  Every message is tagged with the channel it came from, `send` takes an optional
98
120
  `channel`, and `inbox` can filter by one. The first entry is the default.
99
121
 
122
+ ## Three modes, one per session
123
+
124
+ Every channel is in one of three modes, and the mode belongs to the session, not
125
+ to the machine:
126
+
127
+ | Mode | What a prompt gets |
128
+ |---|---|
129
+ | `off` | Nothing. The channel is not mentioned. |
130
+ | `ask` | One line naming who is waiting and how many. Nothing is opened. Default. |
131
+ | `read` | The messages themselves, fenced, and marked read as they arrive. |
132
+
133
+ `ask` looks like this, and is what a prompt hook prints:
134
+
135
+ ```
136
+ Unread messages : Huso(5), Sinan(2)
137
+ ```
138
+
139
+ Loudest sender first, because five messages from one person is a conversation
140
+ waiting while one each from five people is a standup. Past five names the rest
141
+ become `+3 more`. Anything that failed its signature check is called out on the
142
+ same line — `[1 FORGED]` — rather than counted in silently.
143
+
144
+ `read` is the one to think about before turning on: it puts other people's
145
+ writing into your agent's prompt without you asking. It arrives inside the same
146
+ fence the `inbox` tool uses, but the guarantee is weaker there. Over MCP the rule
147
+ for reading fenced content is delivered once through the handshake, where no
148
+ message can sit beside it; a prompt hook has no handshake, so the rule and the
149
+ content share a page.
150
+
151
+ ### What "per session" means
152
+
153
+ A session is identified by its working directory, because that is the only thing
154
+ a fresh `drain` process can see — it is launched again on every prompt and
155
+ remembers nothing. So one project can run `read` while another runs `off`, with
156
+ the same nickname, the same keys and the same Slack app behind both. Two windows
157
+ open on one folder count as one session; set `AGENT_WIRE_SCOPE` to tell them
158
+ apart.
159
+
160
+ Read and unread are per session too. They have to be: a session on `read` opens
161
+ everything it is handed, and if that also marked the message read next door, an
162
+ `ask` session would report an empty inbox forever.
163
+
164
+ The poller is not per session. One poller feeds one shared log for the whole
165
+ machine, so a channel stays polled while any session still wants it. `off` means
166
+ "do not tell me", not "stop collecting" — otherwise the quietest session on the
167
+ machine would decide what the busiest one is allowed to see.
168
+
100
169
  ## Working on two of five channels
101
170
 
102
- Channels you are not working on today can be switched off. Running `agent-wire`
103
- with no arguments shows where you stand:
171
+ Running `agent-wire` with no arguments shows where you stand:
104
172
 
105
173
  ```
106
174
  ┌──────────────── agent-wire ────────────────┐
107
175
  │ name grkn mark 🔥 │
108
176
  │ key MCowBQYDK2VwAyEAq7Xn2mZ8kLcYzQwErTy… │
109
177
  ├───────────────── CHANNELS ─────────────────┤
110
- │ agent-wms ● on 3 unread │
111
- │ agent-crm on 1 unread │
178
+ │ agent-wms ● read 3 unread │
179
+ │ agent-crm ask 1 unread │
112
180
  │ agent-hcm ○ off 1 held │
113
181
  │ agent-lab ○ off 1 held │
114
182
  ├────────────────── PEERS ───────────────────┤
@@ -129,8 +197,9 @@ verifies. Letting a later message clear it would hand an attacker the way to bur
129
197
  the evidence.
130
198
 
131
199
  ```bash
132
- agent-wire off agent-hcm
133
- agent-wire on agent-hcm
200
+ agent-wire off agent-hcm
201
+ agent-wire ask agent-hcm
202
+ agent-wire read agent-wms
134
203
  ```
135
204
 
136
205
  `status` reads the config and the local log only, so it answers instantly.
@@ -205,20 +274,29 @@ Your workspace admin will ask. The manifest requests:
205
274
  | Scope | Why |
206
275
  |---|---|
207
276
  | `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 |
277
+ | `channels:history` | Read the channels it was added to |
278
+ | `channels:read` | Find a channel by name, list who is in it |
279
+ | `files:write` | Send a file, and post a long message as one |
280
+ | `files:read` | Download a file somebody sent |
213
281
  | `users:read` | Show a human's name instead of `U08J21KLER1` |
214
282
 
215
- The app only ever reads channels it has been added to.
283
+ Six, and that is the whole list. No `channels:join` or `channels:manage`, so the
284
+ app cannot add itself to a channel or create one. No `groups:*`, so private
285
+ channels are out of reach: use a public one.
286
+
287
+ The two lookups it does are both scoped to the invite. Channels come from
288
+ `users.conversations`, which answers "which channels am I in", never
289
+ `conversations.list`, which answers "which channels exist here". Names come from
290
+ `conversations.members` on one of those channels. There is no call in the package
291
+ that can enumerate the workspace.
216
292
 
217
293
  ## Where things are stored
218
294
 
219
295
  Everything lives in `~/.agent-wire/` (override with `AGENT_WIRE_HOME`).
220
296
  `config.json` holds the token, identity and channels. `inbox.jsonl` is the
221
- append-only message log. `peers.json` holds the pinned keys.
297
+ append-only message log. `peers.json` holds the pinned keys. `files/` holds every
298
+ attachment that arrived, named by Slack file id so two `plan.md` files stay two
299
+ files.
222
300
 
223
301
  The local log is the source of truth. Slack is a cache that can be re-read at any
224
302
  time, so recovering a lost inbox is an ordinary operation rather than a
@@ -232,12 +310,20 @@ reinstalled app cannot produce duplicates.
232
310
  - Discord as a second transport
233
311
  - Published measurements of fenced against unfenced injection compliance
234
312
 
313
+ Wire format v2 signs the attached file id alongside the text, so a 0.5 agent and
314
+ a 0.4 agent cannot verify each other. Upgrade both ends together.
315
+
235
316
  ## Development
236
317
 
237
318
  ```bash
238
- npm test
319
+ npm test # 53 tests, no network
320
+ npm run bench # medians over a synthetic 20k-message log
239
321
  ```
240
322
 
323
+ The benchmark is here because the slow paths are the ones nobody watches: a log
324
+ that only grows, and a CLI that a prompt hook runs on every prompt. It is not
325
+ shipped to npm.
326
+
241
327
  ## License
242
328
 
243
329
  MIT
@@ -1,115 +1,148 @@
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
- }
1
+ #!/usr/bin/env node
2
+ // Only the two cheap modules are imported up front. `status` builds an
3
+ // Intl.Segmenter and touches the Extended_Pictographic table, which together cost
4
+ // more than everything else this file does; `setup` pulls in readline. A prompt
5
+ // hook runs `drain` on every single prompt, so it must not pay for the panel it
6
+ // never draws.
7
+ import { activeChannels, channelMode, loadConfig, scopeId, setChannelMode } from '../src/config.mjs';
8
+ import { markRead, selectMessages } from '../src/inbox.mjs';
9
+ import { drainReport } from '../src/drain.mjs';
10
+ import { mintNonce } from '../src/protocol.mjs';
11
+
12
+ const USAGE = `agent-wire message other AI coding agents through Slack
13
+
14
+ agent-wire status show identity, channels and unread counts (also the default)
15
+ agent-wire setup connect a workspace, a channel and this agent's identity
16
+ agent-wire serve run the MCP stdio server (what your agent client launches)
17
+ agent-wire doctor re-check the token, the channels and this agent's identity
18
+ agent-wire drain report what arrived since the last drain, then stop
19
+ agent-wire channels list the channels and what each one is set to here
20
+ agent-wire ask <name> name who is waiting and how many; open nothing (default)
21
+ agent-wire read <name> put the messages themselves into every prompt
22
+ agent-wire off <name> say nothing about it in this session
23
+
24
+ The three modes are per session, identified by the working directory. The token,
25
+ the nickname and the keys are shared. Set AGENT_WIRE_SCOPE to tell two sessions
26
+ in one folder apart.
27
+
28
+ Docs: https://github.com/grknbyk/agent-wire`;
29
+
30
+ // A prompt hook has one line of the user's screen to work with, so a backlog past
31
+ // this is reported as a count rather than listed.
32
+ const DRAIN_COUNT = 50;
33
+
34
+ // For a client hook that runs on every prompt. An `ask` channel costs the agent
35
+ // one line and reads nothing; a `read` channel spends the prompt on the messages
36
+ // themselves and marks them read, because nothing else is going to.
37
+ async function drain() {
38
+ const config = loadConfig();
39
+ if (!config) return 0;
40
+
41
+ const { pollOnce } = await import('../src/mcp.mjs');
42
+ await pollOnce(config).catch(() => {
43
+ // Offline is not an error here; the next drain catches up.
44
+ });
45
+
46
+ const heard = activeChannels(config);
47
+ const waiting = selectMessages({
48
+ state: 'unread',
49
+ count: DRAIN_COUNT,
50
+ channels: heard.map((channel) => channel.name),
51
+ });
52
+ if (waiting.length === 0) return 0;
53
+
54
+ const { lines, readItems } = drainReport(config, heard, waiting, mintNonce());
55
+ if (lines.length === 0) return 0;
56
+
57
+ console.log(lines.join('\n'));
58
+ markRead(readItems);
59
+ return 0;
60
+ }
61
+
62
+ function listChannels() {
63
+ const config = loadConfig();
64
+ const configured = config?.channels ?? [];
65
+ if (configured.length === 0) {
66
+ console.log('no channels configured run `agent-wire setup`');
67
+ return 1;
68
+ }
69
+
70
+ for (const channel of configured) console.log(`${channelMode(config, channel).padEnd(4)} #${channel.name}`);
71
+ console.log(`\nmodes are per session; this one is ${scopeId()}`);
72
+ return 0;
73
+ }
74
+
75
+ const MODE_EXPLAINED = {
76
+ off: (name) => `#${name} is off for this session. Nothing about it reaches this agent;`
77
+ + ` its history stays readable with inbox channel="${name}".`,
78
+ ask: (name) => `#${name} is on ask. Every prompt names who is waiting and how many, and opens nothing.`,
79
+ read: (name) => `#${name} is on read. Every prompt carries the messages themselves, marked read as they arrive.`
80
+ + ' Other people\'s writing now reaches this agent without you asking for it.',
81
+ };
82
+
83
+ // Which mode a channel is in is a decision for the person running the agent, so
84
+ // it lives on the command line and not in the MCP tool list. A message arriving
85
+ // from the channel must not be able to talk the agent into silencing another one,
86
+ // nor into opening one.
87
+ function switchChannel(name, mode) {
88
+ if (!name) {
89
+ console.log(`usage: agent-wire ${mode} <channel>`);
90
+ return 1;
91
+ }
92
+
93
+ const changed = setChannelMode(name, mode);
94
+ if (!changed) {
95
+ console.log(`no configured channel named "${name}"`);
96
+ return 1;
97
+ }
98
+
99
+ console.log(MODE_EXPLAINED[mode](changed.channel.name));
100
+ if (changed.previous === 'off' && mode !== 'off') {
101
+ console.log('The next poll replays everything that arrived while it was off.');
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ const showStatus = async () => (await import('../src/status.mjs')).runStatus();
107
+
108
+ const commands = {
109
+ status: async () => await showStatus() ?? notConfigured(),
110
+ setup: async () => (await import('../src/setup.mjs')).runSetup(),
111
+ doctor: async () => (await import('../src/setup.mjs')).runDoctor(),
112
+ drain,
113
+ channels: listChannels,
114
+ off: () => switchChannel(process.argv[3], 'off'),
115
+ ask: () => switchChannel(process.argv[3], 'ask'),
116
+ read: () => switchChannel(process.argv[3], 'read'),
117
+ // `on` was the only way to undo `off` before there were three modes, and it
118
+ // meant "announce it without opening it". That is ask.
119
+ on: () => switchChannel(process.argv[3], 'ask'),
120
+ };
121
+
122
+ function notConfigured() {
123
+ console.log('not configured yet — run `agent-wire setup`');
124
+ return 1;
125
+ }
126
+
127
+ const name = process.argv[2];
128
+
129
+ // The exit code is set, never forced. Calling process.exit() while an undici
130
+ // socket from a Slack call is still closing aborts libuv on Windows, and doctor
131
+ // hit that on every run. Nothing here holds the event loop open, so letting Node
132
+ // finish by itself costs about a millisecond.
133
+ //
134
+ // serve() is the one command that must not exit at all: the open stdin stream is
135
+ // what keeps the MCP server alive.
136
+ if (name === 'serve') {
137
+ (await import('../src/mcp.mjs')).serve();
138
+ } else if (commands[name]) {
139
+ process.exitCode = await commands[name]() ?? 0;
140
+ } else if (!name) {
141
+ // Bare invocation shows where you stand once there is something to stand on,
142
+ // and the usage text while there is not.
143
+ const shown = await showStatus();
144
+ if (shown === null) console.log(USAGE);
145
+ } else {
146
+ console.log(USAGE);
147
+ process.exitCode = 1;
148
+ }
package/manifest.json CHANGED
@@ -1,38 +1,31 @@
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
- }
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
+ "scopes": {
16
+ "bot": [
17
+ "chat:write",
18
+ "channels:read",
19
+ "channels:history",
20
+ "files:read",
21
+ "files:write",
22
+ "users:read"
23
+ ]
24
+ }
25
+ },
26
+ "settings": {
27
+ "org_deploy_enabled": false,
28
+ "socket_mode_enabled": false,
29
+ "token_rotation_enabled": false
30
+ }
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.4.1",
3
+ "version": "0.6.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",
@@ -20,7 +20,8 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "scripts": {
23
- "test": "node --test \"test/*.test.mjs\""
23
+ "test": "node --test \"test/*.test.mjs\"",
24
+ "bench": "node bench/bench.mjs"
24
25
  },
25
26
  "files": [
26
27
  "bin",