@grknbyk/agent-wire 0.8.0 → 0.8.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 CHANGED
@@ -83,7 +83,13 @@ Or, for any other MCP client:
83
83
 
84
84
  ## Tools your agent gets
85
85
 
86
- `send`, `send_file`, `inbox`, `archive`, `peers`, `members`, `channels`, `my_id`.
86
+ `send`, `send_file`, `inbox`, `archive`, `peers`, `members`, `channels`, `my_id`,
87
+ `status`.
88
+
89
+ `status` returns the same card the CLI draws, already fenced. It exists because a
90
+ shell result gets read, understood and then retyped as prose, and a drawn box does
91
+ not survive that — two installs reporting the same state should not produce two
92
+ different-looking answers.
87
93
 
88
94
  The mode of a channel is a command the user runs, never a tool. A message
89
95
  arriving from the channel must not be able to talk the agent into silencing
@@ -136,7 +142,7 @@ to the machine:
136
142
  `ask` looks like this, and is what a prompt hook prints:
137
143
 
138
144
  ```
139
- Unread messages : Huso(5), Sinan(2)
145
+ Unread messages : mira(5), kai(2)
140
146
  ```
141
147
 
142
148
  Loudest sender first, because five messages from one person is a conversation
@@ -20,6 +20,7 @@ const USAGE = `agent-wire — message other AI coding agents through Slack
20
20
  agent-wire ask <name> name who is waiting and how many; open nothing (default)
21
21
  agent-wire read <name> put the messages themselves into every prompt
22
22
  agent-wire off <name> say nothing about it in this session
23
+ agent-wire version print the installed version, which is what a bug report needs
23
24
 
24
25
  The three modes belong to one session, identified by the client's session id when
25
26
  it publishes one and by the working directory otherwise. The token, the nickname
@@ -113,6 +114,19 @@ function switchChannel(name, mode) {
113
114
 
114
115
  const showStatus = async () => (await import('../src/status.mjs')).runStatus();
115
116
 
117
+ // Everybody types one of these three before they report anything, so all three
118
+ // answer. package.json is read here rather than imported at the top because
119
+ // `drain` runs on every prompt and never needs it.
120
+ async function showVersion() {
121
+ const { readFileSync } = await import('node:fs');
122
+ const { fileURLToPath } = await import('node:url');
123
+ const { dirname, join } = await import('node:path');
124
+
125
+ const packageJson = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
126
+ console.log(JSON.parse(readFileSync(packageJson, 'utf8')).version);
127
+ return 0;
128
+ }
129
+
116
130
  const commands = {
117
131
  status: async () => await showStatus() ?? notConfigured(),
118
132
  setup: async () => (await import('../src/setup.mjs')).runSetup(),
@@ -125,6 +139,9 @@ const commands = {
125
139
  // `on` was the only way to undo `off` before there were three modes, and it
126
140
  // meant "announce it without opening it". That is ask.
127
141
  on: () => switchChannel(process.argv[3], 'ask'),
142
+ version: showVersion,
143
+ '--version': showVersion,
144
+ '-v': showVersion,
128
145
  };
129
146
 
130
147
  function notConfigured() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Let AI coding agents message each other through a shared Slack channel, over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/drain.mjs CHANGED
@@ -9,7 +9,7 @@ import { renderEnvelope } from './protocol.mjs';
9
9
  // has to scroll is a hook line the user stops reading.
10
10
  const SENDERS_SHOWN = 5;
11
11
 
12
- // "Huso(5), Sinan(2)", loudest first, so the name that matters is the first thing
12
+ // "mira(5), kai(2)", loudest first, so the name that matters is the first thing
13
13
  // on the line. The count per person is the whole point: five messages from one
14
14
  // person is a conversation waiting, one each from five people is a standup.
15
15
  export function senderTally(items) {
package/src/inbox.mjs CHANGED
@@ -38,7 +38,12 @@ export function readInbox() {
38
38
  // per appended message is the whole cost of appending a message.
39
39
  const inboxKeys = () => derivedFromFile(paths.inbox, 'keys', () => new Set(readInbox().map(logKey)));
40
40
 
41
- export const stateOf = (states, item) => states[storageKey(item)] ?? 'unread';
41
+ // A message this agent sent is kept in the log so the conversation reads back
42
+ // whole, but it was never waiting on anybody. Left as unread it joined the tally
43
+ // of who needs an answer, and the agent was told ten people were waiting when one
44
+ // of them was itself.
45
+ export const stateOf = (states, item) =>
46
+ (item.authorship === 'self' ? 'read' : states[storageKey(item)] ?? 'unread');
42
47
 
43
48
  // The Slack timestamp is the idempotency key: a retried poll, an overlapping
44
49
  // window, or a re-installed app all replay the same ts, and a duplicate the human
@@ -80,10 +85,65 @@ export function selectMessages({ state = 'unread', count = DEFAULT_COUNT, channe
80
85
  return picked.reverse();
81
86
  }
82
87
 
88
+ // A session id is minted per client session, so read state stored under one is
89
+ // unreachable the moment that session ends — no later process ever asks under
90
+ // that key again. Left alone the file grows by one key per message per session:
91
+ // measured at 14 MB and 79 ms per mark after a thousand sessions, and marking is
92
+ // something a `read` session does on every prompt.
93
+ //
94
+ // Whole scopes go, oldest first, ranked by the newest message each one has seen.
95
+ // A session still running has recent timestamps and survives; only the dead ones
96
+ // are cheap enough to lose. The current scope is never a candidate.
97
+ const STATE_KEYS_MAX = 8000;
98
+ const STATE_KEYS_KEEP = 6000;
99
+
100
+ const scopeOfKey = (key) => {
101
+ const bar = key.indexOf('|');
102
+ return bar === -1 ? '' : key.slice(0, bar); // written before scopes existed
103
+ };
104
+
105
+ function prunedStates(states) {
106
+ const keys = Object.keys(states);
107
+ if (keys.length <= STATE_KEYS_MAX) return states;
108
+
109
+ // One session that has simply read a lot owns every key here, and none of them
110
+ // can be dropped. Counting them costs a startsWith per key and no allocation,
111
+ // where grouping by scope costs a substring per key: 20k keys went from 0.7 ms
112
+ // to 13 ms of scanning that always freed nothing.
113
+ const mine = `${scopeId()}|`;
114
+ let foreign = 0;
115
+ for (const key of keys) if (!key.startsWith(mine)) foreign++;
116
+ if (foreign === 0) return states;
117
+
118
+ const keysByScope = new Map();
119
+ const newestByScope = new Map();
120
+ for (const key of keys) {
121
+ const scope = scopeOfKey(key);
122
+ const timestamp = Number(key.slice(key.lastIndexOf(':') + 1)) || 0;
123
+ if (!keysByScope.has(scope)) keysByScope.set(scope, []);
124
+ keysByScope.get(scope).push(key);
125
+ if (timestamp > (newestByScope.get(scope) ?? 0)) newestByScope.set(scope, timestamp);
126
+ }
127
+
128
+ const stale = [...newestByScope]
129
+ .filter(([scope]) => scope !== scopeId())
130
+ .sort(([, left], [, right]) => left - right);
131
+
132
+ // Down to the low mark rather than to the cap, or the next write is over it
133
+ // again and pays for the whole scan a second time.
134
+ let remaining = keys.length;
135
+ for (const [scope] of stale) {
136
+ if (remaining <= STATE_KEYS_KEEP) break;
137
+ for (const key of keysByScope.get(scope)) delete states[key];
138
+ remaining -= keysByScope.get(scope).length;
139
+ }
140
+ return states;
141
+ }
142
+
83
143
  export function markRead(items) {
84
144
  const states = readJsonCached(paths.states, {});
85
145
  for (const item of items) states[storageKey(item)] = 'read';
86
- writeJson(paths.states, states);
146
+ writeJson(paths.states, prunedStates(states));
87
147
  }
88
148
 
89
149
  export function archive(ts) {
@@ -92,7 +152,7 @@ export function archive(ts) {
92
152
  ? readInbox().filter((item) => item.ts === ts)
93
153
  : readInbox().filter((item) => stateOf(states, item) === 'read');
94
154
  for (const item of targets) states[storageKey(item)] = 'archived';
95
- writeJson(paths.states, states);
155
+ writeJson(paths.states, prunedStates(states));
96
156
  return targets.length;
97
157
  }
98
158
 
package/src/mcp.mjs CHANGED
@@ -64,6 +64,11 @@ const TOOLS = [
64
64
  description: 'This agent\'s nickname, emoji, key fingerprint and channels.',
65
65
  inputSchema: { type: 'object', properties: {} },
66
66
  },
67
+ {
68
+ name: 'status',
69
+ description: 'The status card: identity, channels with their modes, who has written, and when the last poll ran. Print what this returns exactly as it arrives, inside a code block. It is a drawn box, so retyping the fields loses it.',
70
+ inputSchema: { type: 'object', properties: {} },
71
+ },
67
72
  {
68
73
  name: 'peers',
69
74
  description: 'Agent names seen in the channels so far, with the key pinned to each.',
@@ -154,11 +159,26 @@ const MODE_SUMMARY = {
154
159
  read: 'the messages themselves, in every prompt',
155
160
  };
156
161
 
157
- const PROMPTS = MODES.map((mode) => ({
158
- name: mode,
159
- description: `Set a channel to ${mode} for this session — ${MODE_SUMMARY[mode]}`,
160
- arguments: [{ name: 'channel', description: 'Channel name. Omit it when only one is configured.', required: false }],
161
- }));
162
+ const PROMPTS = [
163
+ ...MODES.map((mode) => ({
164
+ name: mode,
165
+ description: `Set a channel to ${mode} for this session ${MODE_SUMMARY[mode]}`,
166
+ arguments: [{ name: 'channel', description: 'Channel name. Omit it when only one is configured.', required: false }],
167
+ })),
168
+ { name: 'status', description: 'Show the agent-wire status card', arguments: [] },
169
+ ];
170
+
171
+ const STATUS_INSTRUCTION = {
172
+ description: 'Show the agent-wire status card',
173
+ messages: [{
174
+ role: 'user',
175
+ content: {
176
+ type: 'text',
177
+ text: 'Call the agent-wire status tool and print what it returns verbatim, inside a code block.'
178
+ + ' Do not summarise it, do not retype the fields, do not reformat the box. The drawing is the answer.',
179
+ },
180
+ }],
181
+ };
162
182
 
163
183
  function modeInstruction(mode, channel) {
164
184
  const command = `agent-wire ${mode}${channel ? ` ${channel}` : ''}`;
@@ -321,7 +341,19 @@ async function call(name, args, session) {
321
341
  if (missing.length) return `missing or empty: ${missing.join(', ')}`;
322
342
 
323
343
  const config = loadConfig();
324
- if (!config) return 'agent-wire is not configured yet run `npx @grknbyk/agent-wire setup`';
344
+ // Said to an agent, which will pass it on. Naming the terminal matters: setup
345
+ // refuses a pipe, so an agent that tries to run it from a tool gets a bare
346
+ // refusal and tells the user the wrong thing.
347
+ if (!config) return 'agent-wire is not configured yet. Tell the user to run `agent-wire setup` in a real terminal window — it asks questions, so it will not run from a tool. Install it first with `npm i -g @grknbyk/agent-wire` if the command is missing.';
348
+
349
+ // The card reaches the user through a tool rather than a shell, because a
350
+ // shell result gets read, understood and then retyped as prose — and the box
351
+ // does not survive that. Fenced here so it arrives ready to pass on.
352
+ if (name === 'status') {
353
+ const { renderStatus } = await import('./status.mjs');
354
+ return 'Show this to the user exactly as it is, in a code block. Do not summarise it and do not retype the numbers.\n\n'
355
+ + `\`\`\`\n${renderStatus(config).trim()}\n\`\`\``;
356
+ }
325
357
 
326
358
  if (name === 'my_id') {
327
359
  const channels = (config.channels ?? []).map((channel) => `#${channel.name}`).join(', ') || 'none';
@@ -427,7 +459,10 @@ export function serve() {
427
459
  if (message.method === 'prompts/get') {
428
460
  const asked = PROMPTS.find((prompt) => prompt.name === message.params.name);
429
461
  if (!asked) return write({ jsonrpc: '2.0', id: message.id, error: { code: -32602, message: `no prompt named ${message.params.name}` } });
430
- return write({ jsonrpc: '2.0', id: message.id, result: modeInstruction(asked.name, message.params.arguments?.channel) });
462
+ const answer = asked.name === 'status'
463
+ ? STATUS_INSTRUCTION
464
+ : modeInstruction(asked.name, message.params.arguments?.channel);
465
+ return write({ jsonrpc: '2.0', id: message.id, result: answer });
431
466
  }
432
467
  if (message.method === 'ping') return write({ jsonrpc: '2.0', id: message.id, result: {} });
433
468
  if (message.method === 'tools/call') {
package/src/setup.mjs CHANGED
@@ -169,7 +169,7 @@ export async function runSetup() {
169
169
  export async function runDoctor() {
170
170
  const config = loadConfig();
171
171
  if (!config?.bot_token) {
172
- console.log('not configured — run `npx @grknbyk/agent-wire setup`');
172
+ console.log('not configured yet — run `agent-wire setup`');
173
173
  return 1;
174
174
  }
175
175