@inerrata/channel 0.3.1 → 0.3.3

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.
Files changed (2) hide show
  1. package/dist/index.js +53 -20
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
- import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
17
+ import { ListToolsRequestSchema, CallToolRequestSchema, InitializedNotificationSchema, } from '@modelcontextprotocol/sdk/types.js';
18
18
  const MCP_BASE = (process.env.ERRATA_API_URL ?? 'https://inerrata.fly.dev').replace('/api/v1', '');
19
19
  const API_BASE = MCP_BASE + '/api/v1';
20
20
  const API_KEY = process.env.ERRATA_API_KEY ?? '';
@@ -26,10 +26,14 @@ if (!API_KEY) {
26
26
  // MCP Server — low-level Server class (McpServer does NOT propagate
27
27
  // the claude/channel experimental capability)
28
28
  // ---------------------------------------------------------------------------
29
+ // Set after the client sends notifications/initialized — true only for Claude Code
30
+ // which declares experimental: { 'claude/channel': {} } in its capabilities.
31
+ let clientSupportsChannelNotif = false;
29
32
  const server = new Server({ name: 'inerrata-channel', version: '0.1.0' }, {
30
33
  capabilities: {
31
34
  experimental: { 'claude/channel': {} },
32
35
  tools: {},
36
+ logging: {},
33
37
  },
34
38
  instructions: `You are connected to inErrata messaging via a real-time channel.
35
39
 
@@ -43,6 +47,10 @@ Message types:
43
47
  - message.received: A new message in an established conversation
44
48
  - message.request: A first-contact request from a new agent (includes their profile)`,
45
49
  });
50
+ // Detect client type after handshake — only Claude Code declares claude/channel support
51
+ server.setNotificationHandler(InitializedNotificationSchema, async () => {
52
+ clientSupportsChannelNotif = !!server.getClientCapabilities()?.experimental?.['claude/channel'];
53
+ });
46
54
  // ---------------------------------------------------------------------------
47
55
  // Tools — reply + accept_request
48
56
  // ---------------------------------------------------------------------------
@@ -138,13 +146,22 @@ async function pushNotification(data) {
138
146
  const notifId = data.requestId ?? data.messageId;
139
147
  if (notifId && isDuplicate(notifId))
140
148
  return;
141
- await server.notification({
142
- method: 'notifications/claude/channel',
143
- params: {
144
- content: data.content ?? '',
145
- meta: data.meta ?? {},
146
- },
147
- });
149
+ const content = data.content ?? '';
150
+ const meta = data.meta ?? {};
151
+ if (clientSupportsChannelNotif) {
152
+ // Claude Code: renders as a <channel> tag in the conversation
153
+ await server.notification({
154
+ method: 'notifications/claude/channel',
155
+ params: { content, meta },
156
+ });
157
+ }
158
+ else {
159
+ // Copilot / other clients: standard MCP logging notification
160
+ await server.notification({
161
+ method: 'notifications/message',
162
+ params: { level: 'info', logger: 'inErrata', data: content },
163
+ });
164
+ }
148
165
  }
149
166
  catch (err) {
150
167
  console.error('[inerrata-channel] Failed to push notification:', err);
@@ -152,6 +169,14 @@ async function pushNotification(data) {
152
169
  }
153
170
  // ---------------------------------------------------------------------------
154
171
  // Welcome banner — fetches agent profile and pushes on first connect
172
+ async function pushWelcomeNotif(content, meta) {
173
+ if (clientSupportsChannelNotif) {
174
+ await server.notification({ method: 'notifications/claude/channel', params: { content, meta } });
175
+ }
176
+ else {
177
+ await server.notification({ method: 'notifications/message', params: { level: 'info', logger: 'inErrata', data: content } });
178
+ }
179
+ }
155
180
  // ---------------------------------------------------------------------------
156
181
  async function pushWelcome() {
157
182
  try {
@@ -169,26 +194,23 @@ async function pushWelcome() {
169
194
  const inboxMessages = inboxRes.ok
170
195
  ? (await inboxRes.json())
171
196
  : [];
172
- const onlineConnections = connections.filter(c => c.online).map(c => c.handle);
197
+ const onlineReachable = connections.filter(c => c.online && c.notifyReachable !== false).map(c => c.handle);
198
+ const onlineMcpOnly = connections.filter(c => c.online && c.notifyReachable === false).map(c => c.handle);
173
199
  const unreadCount = Array.isArray(inboxMessages) ? inboxMessages.filter(m => !m.read).length : 0;
174
200
  if (!me) {
175
- await server.notification({
176
- method: 'notifications/claude/channel',
177
- params: { content: `✦ Connected to inErrata.`, meta: { type: 'welcome' } },
178
- });
201
+ await pushWelcomeNotif(`✦ Connected to inErrata.`, { type: 'welcome' });
179
202
  return;
180
203
  }
181
204
  const level = me.level ?? 1;
182
205
  const xp = me.xp ?? 0;
183
206
  const unreadLabel = unreadCount > 0 ? ` · 💬${unreadCount}` : '';
184
207
  const lines = [`✦ inErrata · @${me.handle} · Lv.${level} · ✨${xp}xp${unreadLabel}`];
185
- if (onlineConnections.length)
186
- lines.push(`┃ 🟢 ${onlineConnections.join(', ')}`);
208
+ if (onlineReachable.length)
209
+ lines.push(`┃ 🟢 ${onlineReachable.join(', ')}`);
210
+ if (onlineMcpOnly.length)
211
+ lines.push(`┃ 🔵 ${onlineMcpOnly.join(', ')}`);
187
212
  lines.push(`✦`);
188
- await server.notification({
189
- method: 'notifications/claude/channel',
190
- params: { content: lines.join('\n'), meta: { type: 'welcome' } },
191
- });
213
+ await pushWelcomeNotif(lines.join('\n'), { type: 'welcome' });
192
214
  }
193
215
  catch {
194
216
  // Best-effort
@@ -248,6 +270,10 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
248
270
  console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
249
271
  // Reset backoff on successful connection
250
272
  retryDelay = 1000;
273
+ // Start heartbeat now that we have a live session — keeps channelOnlineAt
274
+ // in sync with actual push-path availability. Stopped in the finally block.
275
+ if (!heartbeatTimer)
276
+ startHeartbeat();
251
277
  // Send notifications/initialized (MCP protocol requirement) and push
252
278
  // welcome in a single fire-and-forget POST — the initialized notification
253
279
  // tells the server the session is ready, and the welcome greets the user.
@@ -317,6 +343,14 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
317
343
  catch (err) {
318
344
  console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
319
345
  }
346
+ finally {
347
+ // Stop heartbeat when session ends — channelOnlineAt will go stale naturally.
348
+ // This ensures notifyReachable accurately reflects push-path availability.
349
+ if (heartbeatTimer) {
350
+ clearInterval(heartbeatTimer);
351
+ heartbeatTimer = undefined;
352
+ }
353
+ }
320
354
  setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
321
355
  }
322
356
  // ---------------------------------------------------------------------------
@@ -346,7 +380,6 @@ function sendOffline() {
346
380
  async function main() {
347
381
  const transport = new StdioServerTransport();
348
382
  await server.connect(transport);
349
- startHeartbeat();
350
383
  connectAnnouncementChannel().catch(console.error);
351
384
  }
352
385
  // Graceful shutdown — tell the server we're going offline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inerrata/channel",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
5
5
  "type": "module",
6
6
  "files": [