@grknbyk/agent-wire 0.4.1 → 0.5.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.
@@ -1,115 +1,127 @@
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, loadConfig, setChannelActive } from '../src/config.mjs';
8
+ import { markRead, selectMessages } from '../src/inbox.mjs';
9
+
10
+ const USAGE = `agent-wire message other AI coding agents through Slack
11
+
12
+ agent-wire status show identity, channels and unread counts (also the default)
13
+ agent-wire setup connect a workspace, a channel and this agent's identity
14
+ agent-wire serve run the MCP stdio server (what your agent client launches)
15
+ agent-wire doctor re-check the token, the channels and this agent's identity
16
+ agent-wire drain print messages that arrived since the last drain, then stop
17
+ agent-wire channels list the channels and whether each one is switched on
18
+ agent-wire on <name> switch a channel on
19
+ agent-wire off <name> switch a channel off: not polled, not announced
20
+
21
+ Docs: https://github.com/grknbyk/agent-wire`;
22
+
23
+ // A prompt hook has one line of the user's screen to work with, so a backlog past
24
+ // this is reported as a count rather than listed.
25
+ const DRAIN_COUNT = 50;
26
+
27
+ // For a client hook that runs on every prompt: says a message is waiting without
28
+ // spending the agent's turn on reading it, and marks nothing as read.
29
+ async function drain() {
30
+ const config = loadConfig();
31
+ if (!config) return 0;
32
+
33
+ const { pollOnce } = await import('../src/mcp.mjs');
34
+ await pollOnce(config).catch(() => {
35
+ // Offline is not an error here; the next drain catches up.
36
+ });
37
+ const waiting = selectMessages({
38
+ state: 'unread',
39
+ count: DRAIN_COUNT,
40
+ channels: activeChannels(config).map((channel) => channel.name),
41
+ });
42
+ if (waiting.length === 0) return 0;
43
+
44
+ const senders = [...new Set(waiting.map((item) => item.from))].join(', ');
45
+ console.log(`agent-wire: ${waiting.length} unread message(s) from ${senders}.`
46
+ + ' Tell the user in one line. Do not read them unless asked — use the agent-wire inbox tool.');
47
+ return 0;
48
+ }
49
+
50
+ function listChannels() {
51
+ const config = loadConfig();
52
+ const configured = config?.channels ?? [];
53
+ if (configured.length === 0) {
54
+ console.log('no channels configured — run `agent-wire setup`');
55
+ return 1;
56
+ }
57
+
58
+ for (const channel of configured) console.log(`${channel.active === false ? 'off' : 'on '} #${channel.name}`);
59
+ return 0;
60
+ }
61
+
62
+ // Switching a channel is a decision for the person running the agent, so it lives
63
+ // on the command line and not in the MCP tool list. A message arriving from the
64
+ // channel must not be able to talk the agent into silencing another one.
65
+ function switchChannel(name, active) {
66
+ if (!name) {
67
+ console.log(`usage: agent-wire ${active ? 'on' : 'off'} <channel>`);
68
+ return 1;
69
+ }
70
+
71
+ const channel = setChannelActive(name, active);
72
+ if (!channel) {
73
+ console.log(`no configured channel named "${name}"`);
74
+ return 1;
75
+ }
76
+
77
+ console.log(active
78
+ ? `#${channel.name} is on. The next poll replays everything since it was switched off.`
79
+ : `#${channel.name} is off. It is no longer polled or announced; its history stays readable with inbox channel="${channel.name}".`);
80
+ return 0;
81
+ }
82
+
83
+ const showStatus = async () => (await import('../src/status.mjs')).runStatus();
84
+
85
+ const commands = {
86
+ status: async () => await showStatus() ?? notConfigured(),
87
+ setup: async () => (await import('../src/setup.mjs')).runSetup(),
88
+ doctor: async () => (await import('../src/setup.mjs')).runDoctor(),
89
+ drain,
90
+ channels: listChannels,
91
+ on: () => switchChannel(process.argv[3], true),
92
+ off: () => switchChannel(process.argv[3], false),
93
+ read: async () => {
94
+ const items = selectMessages({ state: 'unread', count: DRAIN_COUNT });
95
+ for (const item of items) console.log(`[${item.authorship}] ${item.at} ${item.from}: ${item.text}`);
96
+ markRead(items);
97
+ return 0;
98
+ },
99
+ };
100
+
101
+ function notConfigured() {
102
+ console.log('not configured yet — run `agent-wire setup`');
103
+ return 1;
104
+ }
105
+
106
+ const name = process.argv[2];
107
+
108
+ // The exit code is set, never forced. Calling process.exit() while an undici
109
+ // socket from a Slack call is still closing aborts libuv on Windows, and doctor
110
+ // hit that on every run. Nothing here holds the event loop open, so letting Node
111
+ // finish by itself costs about a millisecond.
112
+ //
113
+ // serve() is the one command that must not exit at all: the open stdin stream is
114
+ // what keeps the MCP server alive.
115
+ if (name === 'serve') {
116
+ (await import('../src/mcp.mjs')).serve();
117
+ } else if (commands[name]) {
118
+ process.exitCode = await commands[name]() ?? 0;
119
+ } else if (!name) {
120
+ // Bare invocation shows where you stand once there is something to stand on,
121
+ // and the usage text while there is not.
122
+ const shown = await showStatus();
123
+ if (shown === null) console.log(USAGE);
124
+ } else {
125
+ console.log(USAGE);
126
+ process.exitCode = 1;
127
+ }
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.5.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",
package/src/config.mjs CHANGED
@@ -1,68 +1,114 @@
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
- }
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, statSync, 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
+ // mtime and size together, or null when the file is not there yet. Both move on
23
+ // every write, and they move for a write from any process, which is what makes
24
+ // this safe to cache on: the poller and three agent sessions all share these
25
+ // files and none of them can tell the others it wrote.
26
+ function stampOf(file) {
27
+ try {
28
+ const stat = statSync(file);
29
+ return `${stat.mtimeMs}:${stat.size}`;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ const parsedByFile = new Map();
36
+
37
+ // Re-parsing states.json on every message is most of what reading an inbox costs
38
+ // once a log gets long, and the parse is pure waste when nothing wrote in
39
+ // between. Callers get the cached object itself, not a copy — every caller here
40
+ // either only reads it, or mutates it and writes immediately after.
41
+ export function readJsonCached(file, fallback) {
42
+ const stamp = stampOf(file);
43
+ if (!stamp) return fallback;
44
+
45
+ const cached = parsedByFile.get(file);
46
+ if (cached && cached.stamp === stamp) return cached.value;
47
+
48
+ const value = JSON.parse(readFileSync(file, 'utf8'));
49
+ parsedByFile.set(file, { stamp, value });
50
+ return value;
51
+ }
52
+
53
+ // Cache anything else derived from one file's contents — a parsed log, an index
54
+ // built from it — under the same stamp, so it is thrown away exactly when the
55
+ // parse behind it is.
56
+ export function derivedFromFile(file, key, build) {
57
+ const stamp = stampOf(file);
58
+ const cacheKey = `${file}#${key}`;
59
+ const cached = parsedByFile.get(cacheKey);
60
+ if (cached && cached.stamp === stamp) return cached.value;
61
+
62
+ const value = build();
63
+ parsedByFile.set(cacheKey, { stamp, value });
64
+ return value;
65
+ }
66
+
67
+ // Write to a sibling then rename: a config half-written by a killed setup run is
68
+ // how an install becomes unrecoverable, and rename is atomic on every platform we
69
+ // target. The temp name carries the pid so two runs cannot share it.
70
+ export function writeJson(file, value) {
71
+ mkdirSync(HOME, { recursive: true });
72
+ const tempFile = `${file}.${process.pid}.tmp`;
73
+ writeFileSync(tempFile, JSON.stringify(value, null, 2));
74
+ renameSync(tempFile, file);
75
+ parsedByFile.set(file, { stamp: stampOf(file), value });
76
+ }
77
+
78
+ export const loadConfig = () => readJson(paths.config, null);
79
+
80
+ export const saveConfig = (config) => writeJson(paths.config, config);
81
+
82
+ // Setup writes after every completed step, so the config IS the resume state and
83
+ // there is no second progress file to disagree with it.
84
+ export function patchConfig(patch) {
85
+ const merged = { ...(loadConfig() ?? { version: 1 }), ...patch };
86
+ saveConfig(merged);
87
+ return merged;
88
+ }
89
+
90
+ export const defaultChannel = (config) => config.channels?.[0] ?? null;
91
+
92
+ // A channel is active unless it was explicitly switched off, so a config written
93
+ // before this option existed keeps every channel on.
94
+ export const activeChannels = (config) => (config.channels ?? []).filter((channel) => channel.active !== false);
95
+
96
+ // Switching a channel off leaves its cursor where it is, so switching it back on
97
+ // replays everything that arrived meanwhile instead of losing it.
98
+ export function setChannelActive(name, active) {
99
+ const config = loadConfig();
100
+ if (!config) return null;
101
+
102
+ const channel = findChannel(config, name);
103
+ if (!channel) return null;
104
+
105
+ channel.active = active;
106
+ saveConfig(config);
107
+ return channel;
108
+ }
109
+
110
+ export function findChannel(config, wanted) {
111
+ if (!wanted) return defaultChannel(config);
112
+ const name = String(wanted).replace(/^#/, '').toLowerCase();
113
+ return config.channels?.find((c) => c.name.toLowerCase() === name || c.id === wanted) ?? null;
114
+ }