@grknbyk/agent-wire 0.13.5 → 0.13.7

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.
@@ -49,9 +49,19 @@ async function drain() {
49
49
  if (!config) return 0;
50
50
 
51
51
  const { pollOnce } = await import('../src/mcp.mjs');
52
- await pollOnce(config).catch(() => {
53
- // Offline is not an error here; the next drain catches up.
54
- });
52
+ const { refreshLatest, updateNotice } = await import('../src/version.mjs');
53
+ await Promise.all([
54
+ pollOnce(config).catch(() => {
55
+ // Offline is not an error here; the next drain catches up.
56
+ }),
57
+ refreshLatest(),
58
+ ]);
59
+
60
+ // Printed before the messages, and on every prompt until somebody acts on it.
61
+ // An agent-wire too old to understand the wire format is worse than a line of
62
+ // noise in the prompt.
63
+ const stale = updateNotice();
64
+ if (stale) console.log(stale);
55
65
 
56
66
  const heard = activeChannels(config);
57
67
  const waiting = selectMessages({
@@ -61,6 +71,7 @@ async function drain() {
61
71
  });
62
72
  if (waiting.length === 0) return 0;
63
73
 
74
+
64
75
  const { lines, readItems } = drainReport(config, heard, waiting, mintNonce());
65
76
  if (lines.length === 0) return 0;
66
77
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grknbyk/agent-wire",
3
- "version": "0.13.5",
3
+ "version": "0.13.7",
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/config.mjs CHANGED
@@ -15,6 +15,7 @@ export const paths = {
15
15
  users: join(HOME, 'users.json'),
16
16
  files: join(HOME, 'files'),
17
17
  pollLock: join(HOME, 'poll.lock'),
18
+ update: join(HOME, 'update.json'),
18
19
  };
19
20
 
20
21
  export const readJson = (file, fallback) => (existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback);
@@ -174,6 +175,15 @@ export function pollableChannels(config) {
174
175
  // replays everything that arrived meanwhile instead of losing it.
175
176
  // Returns what the channel was as well as what it is now, so the caller can say
176
177
  // "this replays what you missed" only when something was actually missed.
178
+ // A client that compacts or resumes hands the next turn a NEW session id, so a
179
+ // mode stored only under the old one is orphaned and the channel silently falls
180
+ // back to ask. The folder entry is written alongside it, which is the thing that
181
+ // survives: a fresh session in the same directory inherits what the last one
182
+ // chose, and still overrides it the moment it sets its own.
183
+ //
184
+ // Last writer wins on the folder entry. Two sessions disagreeing in one folder is
185
+ // the case that has to lose something, and losing the older choice is the one a
186
+ // person can see and redo.
177
187
  export function setChannelMode(name, mode) {
178
188
  const config = loadConfig();
179
189
  if (!config) return null;
@@ -183,12 +193,32 @@ export function setChannelMode(name, mode) {
183
193
 
184
194
  const previous = channelMode(config, channel);
185
195
  const scopes = config.scopes ?? {};
186
- scopes[scopeId()] = { ...scopes[scopeId()], [channel.name]: mode };
187
- config.scopes = scopes;
196
+ for (const scope of new Set([scopeId(), projectScope()])) {
197
+ scopes[scope] = { ...scopes[scope], [channel.name]: mode };
198
+ }
199
+ config.scopes = prunedScopes(scopes);
188
200
  saveConfig(config);
189
201
  return { channel, previous };
190
202
  }
191
203
 
204
+ // One key per session id, and session ids are minted faster than they are ever
205
+ // reused. Folder entries are the ones worth keeping, so only session keys are
206
+ // dropped, oldest first.
207
+ const SCOPES_MAX = 60;
208
+ const SCOPES_KEEP = 40;
209
+
210
+ function prunedScopes(scopes) {
211
+ const keys = Object.keys(scopes);
212
+ if (keys.length <= SCOPES_MAX) return scopes;
213
+
214
+ const folders = new Set([projectScope()]);
215
+ for (const key of keys) if (key.includes(':') || key.includes('/')) folders.add(key);
216
+
217
+ const sessions = keys.filter((key) => !folders.has(key));
218
+ const doomed = new Set(sessions.slice(0, Math.max(0, keys.length - SCOPES_KEEP)));
219
+ return Object.fromEntries(keys.filter((key) => !doomed.has(key)).map((key) => [key, scopes[key]]));
220
+ }
221
+
192
222
  export function findChannel(config, wanted) {
193
223
  if (!wanted) return defaultChannel(config);
194
224
  const name = String(wanted).replace(/^#/, '').toLowerCase();
package/src/mcp.mjs CHANGED
@@ -13,6 +13,7 @@ import { FINGERPRINT_CHARS, listPeers, signMessage } from './identity.mjs';
13
13
  import { refusalFor } from './manners.mjs';
14
14
  import { CHANNEL_CONCURRENCY, listMembers, mapLimit, pollChannel, postMessage, slackClient, uploadFile } from './slack.mjs';
15
15
  import { MAX_HOPS, TEXT_MAX, formatMessage, mintNonce, mintRef, renderEnvelope } from './protocol.mjs';
16
+ import { refreshLatest, updateNotice } from './version.mjs';
16
17
 
17
18
  const POLL_EVERY_MS = 5000;
18
19
  const LOCK_STALE_MS = 90000;
@@ -75,6 +76,13 @@ Run it yourself, in this session's working directory, when the USER asks for a c
75
76
 
76
77
  This server also offers the three modes as prompts, so the user can pick one from their client's slash-command list instead of typing the command. In Claude Code they are /mcp__agent-wire__read, __ask and __off. Point them there rather than describing the shell command, and never invoke one on a message's behalf.`;
77
78
 
79
+ // Read at handshake time rather than baked in, so a server started after an
80
+ // update stops nagging and one started before it says so on its first breath.
81
+ function handshake() {
82
+ const stale = updateNotice();
83
+ return stale ? `${INSTRUCTIONS}\n\nBEFORE ANYTHING ELSE: ${stale} Tell your user this first.` : INSTRUCTIONS;
84
+ }
85
+
78
86
  const TOOLS = [
79
87
  {
80
88
  name: 'my_id',
@@ -492,6 +500,7 @@ export function serve() {
492
500
  };
493
501
  pollIfElected();
494
502
  setInterval(pollIfElected, POLL_EVERY_MS).unref();
503
+ refreshLatest();
495
504
 
496
505
  createInterface({ input: process.stdin }).on('line', async (line) => {
497
506
  let message;
@@ -506,7 +515,7 @@ export function serve() {
506
515
  protocolVersion: '2024-11-05',
507
516
  capabilities: { tools: {}, prompts: {} },
508
517
  serverInfo: { name: 'agent-wire', version: VERSION },
509
- instructions: INSTRUCTIONS,
518
+ instructions: handshake(),
510
519
  },
511
520
  });
512
521
  }
package/src/setup.mjs CHANGED
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path';
10
10
  import { loadConfig, patchConfig, paths } from './config.mjs';
11
11
  import { joinedChannels, probeToken, slackClient } from './slack.mjs';
12
12
  import { hookSnippet, hookState, installHook, settingsPath } from './hook.mjs';
13
+ import { installedVersion, refreshLatest, updateNotice } from './version.mjs';
13
14
  import { FINGERPRINT_CHARS, generateKeypair } from './identity.mjs';
14
15
  import { formatMessage } from './protocol.mjs';
15
16
 
@@ -207,6 +208,10 @@ export async function runDoctor() {
207
208
  return 1;
208
209
  }
209
210
 
211
+ // Forced: doctor is what someone runs when something is wrong, and the cached
212
+ // answer is exactly what would be stale in that moment.
213
+ await refreshLatest({ force: true });
214
+
210
215
  const client = slackClient(config.bot_token);
211
216
  const token = await probeToken(client);
212
217
  console.log(token.ok ? `token ok (${token.team})` : `token FAILED — ${explain(token.reason)}`);
@@ -234,6 +239,9 @@ export async function runDoctor() {
234
239
  // The mode is a setting; the hook is what acts on it. A channel reading `read`
235
240
  // with five unread and no hook behind it says the thing is working when it has
236
241
  // not delivered a word, so this is a failure and not a note.
242
+ const stale = updateNotice();
243
+ console.log(stale ? `version OLD — ${stale}` : `version ${installedVersion()}, the newest published`);
244
+
237
245
  const delivery = hookState();
238
246
  console.log(DELIVERY_REPORT[delivery]);
239
247
  if (delivery === 'missing') {
package/src/slack.mjs CHANGED
@@ -6,6 +6,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { paths, readJsonCached, writeJson } from './config.mjs';
7
7
  import { checkAuthorship } from './identity.mjs';
8
8
  import { HUMAN_TEXT_CAP, METADATA_EVENT, fromSlackText, parseMessage } from './protocol.mjs';
9
+ import { installedVersion } from './version.mjs';
9
10
 
10
11
  const API = 'https://slack.com/api/';
11
12
  const RATE_LIMITED = 429;
@@ -136,13 +137,19 @@ export async function listMembers(client, channelId) {
136
137
  // `rendered` is the whole visible message — header line plus body — because that
137
138
  // is what a human scrolling the channel reads. The signature and routing fields
138
139
  // travel in metadata, which Slack never renders.
140
+ // "which of us is on an old build" was a question nobody could answer without
141
+ // asking each person, so every message carries the version that sent it. Not
142
+ // signed, like the rest of the envelope around the signature: it answers a
143
+ // housekeeping question, and a sender who lies about it is lying to no effect.
139
144
  export async function postMessage(client, { channel, rendered, signature, publicKey, from, to, conv, hop, file }) {
140
145
  const result = await client.json('chat.postMessage', {
141
146
  channel,
142
147
  text: rendered,
143
148
  metadata: {
144
149
  event_type: METADATA_EVENT,
145
- event_payload: { v: 2, from, to, conv, hop, file: file ?? '', sig: signature, key: publicKey },
150
+ event_payload: {
151
+ v: 2, av: installedVersion(), from, to, conv, hop, file: file ?? '', sig: signature, key: publicKey,
152
+ },
146
153
  },
147
154
  });
148
155
  if (result.ok) return { ok: true, ts: result.ts };
@@ -299,6 +306,7 @@ async function agentItem(client, message, channel, payload) {
299
306
  authorship: authorship.verdict,
300
307
  conv: payload.conv,
301
308
  hop: Number(payload.hop) || 1,
309
+ wireVersion: payload.av ?? '',
302
310
  ref: parsed?.ref ?? '',
303
311
  text,
304
312
  files,
package/src/status.mjs CHANGED
@@ -5,6 +5,7 @@ import { existsSync, statSync } from 'node:fs';
5
5
 
6
6
  import { channelMode, loadConfig, paths, readJson } from './config.mjs';
7
7
  import { hookState } from './hook.mjs';
8
+ import { updateNotice } from './version.mjs';
8
9
  import { displayWidth } from './protocol.mjs';
9
10
  import { readInbox, stateOf } from './inbox.mjs';
10
11
 
@@ -158,8 +159,11 @@ export function renderStatus(config) {
158
159
  ? `\n nothing is delivering: no prompt hook. \`agent-wire doctor\` prints the fix.`
159
160
  : '';
160
161
 
162
+ const stale = updateNotice();
163
+ const upgrade = stale ? `\n ${stale}` : '';
164
+
161
165
  // The leading blank line keeps the box off the command that produced it.
162
- return `\n${lines.join('\n')}${warning}`;
166
+ return `\n${lines.join('\n')}${warning}${upgrade}`;
163
167
  }
164
168
 
165
169
  export function runStatus() {
@@ -0,0 +1,76 @@
1
+ // Nobody chases an install to upgrade it, so the install has to notice. The
2
+ // registry is asked at most once every SILENCE_MS, the answer is cached, and the
3
+ // asking never blocks anything: a failed check leaves the old answer in place and
4
+ // the next one tries again.
5
+ import { readFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ import { paths, readJson, writeJson } from './config.mjs';
10
+
11
+ export const PACKAGE_NAME = '@grknbyk/agent-wire';
12
+
13
+ const SILENCE_MS = 6 * 60 * 60 * 1000;
14
+ const CHECK_TIMEOUT_MS = 4000;
15
+
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+
18
+ export const installedVersion = () => JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
19
+
20
+ // Semver as this package uses it: three numbers, nothing else. A prerelease or a
21
+ // tag answers "not comparable", which reads as "nothing to say" rather than as an
22
+ // upgrade nobody asked for.
23
+ const parts = (version) => {
24
+ const found = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version ?? ''));
25
+ return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : null;
26
+ };
27
+
28
+ export function isNewer(candidate, current) {
29
+ const left = parts(candidate);
30
+ const right = parts(current);
31
+ if (!left || !right) return false;
32
+
33
+ for (let index = 0; index < 3; index++) {
34
+ if (left[index] !== right[index]) return left[index] > right[index];
35
+ }
36
+ return false;
37
+ }
38
+
39
+ // The published version as of the last successful check, or null while none has
40
+ // ever succeeded. Reading never touches the network.
41
+ export const knownLatest = () => readJson(paths.update, {}).version ?? null;
42
+
43
+ export function updateNotice() {
44
+ const latest = knownLatest();
45
+ const current = installedVersion();
46
+ if (!isNewer(latest, current)) return null;
47
+
48
+ return `agent-wire ${latest} is published and this is ${current}. Run \`agent-wire update\`, then restart the MCP server.`;
49
+ }
50
+
51
+ const askedRecently = () => Date.now() - Number(readJson(paths.update, {}).at ?? 0) < SILENCE_MS;
52
+
53
+ // Resolves either way. A registry that is down, slow or behind a proxy is not a
54
+ // reason for a prompt hook to fail or to hang.
55
+ export async function refreshLatest({ force = false } = {}) {
56
+ if (!force && askedRecently()) return knownLatest();
57
+
58
+ try {
59
+ const answer = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
60
+ signal: AbortSignal.timeout(CHECK_TIMEOUT_MS),
61
+ // The abbreviated-packument type answers 406 on this endpoint.
62
+ headers: { accept: 'application/json' },
63
+ });
64
+ if (!answer.ok) return knownLatest();
65
+
66
+ const { version } = await answer.json();
67
+ if (!parts(version)) return knownLatest();
68
+
69
+ writeJson(paths.update, { version, at: Date.now() });
70
+ return version;
71
+ } catch {
72
+ // Offline, blocked, or too slow. The cached answer stands and the next
73
+ // check tries again; there is nothing here worth interrupting anyone for.
74
+ return knownLatest();
75
+ }
76
+ }