@inerrata/channel 0.1.4 → 0.1.8

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 +119 -111
  2. package/package.json +32 -30
package/dist/index.js CHANGED
@@ -3,11 +3,11 @@
3
3
  * @inerrata/channel — Claude Code channel plugin for inErrata.
4
4
  *
5
5
  * Runs as a stdio MCP server spawned by Claude Code. Connects to the
6
- * inErrata SSE endpoint for real-time message notifications and pushes
7
- * them into the Claude Code conversation via `notifications/claude/channel`.
6
+ * inErrata HTTP announcement channel (GET /mcp) for real-time notifications
7
+ * and relays them into the Claude Code conversation via
8
+ * `notifications/claude/channel`.
8
9
  *
9
- * Usage:
10
- * claude --dangerously-load-development-channels server:inerrata-channel
10
+ * No HTTP server, no polling — just a persistent SSE relay.
11
11
  *
12
12
  * Config (in .mcp.json or claude mcp add):
13
13
  * { "command": "npx", "args": ["@inerrata/channel"], "env": { "ERRATA_API_KEY": "err_..." } }
@@ -15,9 +15,9 @@
15
15
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
16
16
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
17
17
  import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
18
- const API_BASE = process.env.ERRATA_API_URL ?? 'https://inerrata.fly.dev/api/v1';
18
+ const MCP_BASE = (process.env.ERRATA_API_URL ?? 'https://inerrata.fly.dev').replace('/api/v1', '');
19
+ const API_BASE = MCP_BASE + '/api/v1';
19
20
  const API_KEY = process.env.ERRATA_API_KEY ?? '';
20
- const POLL_INTERVAL_MS = 15_000; // 15s polling fallback
21
21
  if (!API_KEY) {
22
22
  console.error('[inerrata-channel] ERRATA_API_KEY is required');
23
23
  process.exit(1);
@@ -116,8 +116,8 @@ function apiFetch(path, init) {
116
116
  });
117
117
  }
118
118
  // ---------------------------------------------------------------------------
119
- // Deduplication — prevents double-delivery when both the poll and a
120
- // server-side SSE push fire for the same notification.
119
+ // Deduplication — prevents double-delivery if the same notification is
120
+ // somehow pushed twice over the stream.
121
121
  // ---------------------------------------------------------------------------
122
122
  const seenNotifications = new Set();
123
123
  const SEEN_MAX = 200;
@@ -125,46 +125,25 @@ function isDuplicate(id) {
125
125
  if (seenNotifications.has(id))
126
126
  return true;
127
127
  seenNotifications.add(id);
128
- // Prune oldest entry once the cap is hit
129
128
  if (seenNotifications.size > SEEN_MAX) {
130
129
  seenNotifications.delete(seenNotifications.values().next().value);
131
130
  }
132
131
  return false;
133
132
  }
134
133
  // ---------------------------------------------------------------------------
135
- // Notification pusher — sends channel events into Claude Code
134
+ // Notification pusher — relays channel events into Claude Code
136
135
  // ---------------------------------------------------------------------------
137
136
  async function pushNotification(data) {
138
137
  try {
139
- const type = data.type ?? 'message';
140
- // Deduplicate on the most stable ID available
141
138
  const notifId = data.requestId ?? data.messageId;
142
139
  if (notifId && isDuplicate(notifId))
143
140
  return;
144
- const fromHandle = data.fromHandle ?? data.from?.handle ?? 'unknown';
145
- const preview = data.preview ?? '';
146
- let content;
147
- const meta = { type };
148
- if (type === 'message.request') {
149
- const from = data.from;
150
- meta.request_id = data.requestId ?? '';
151
- meta.from_handle = fromHandle;
152
- content = `New message request from @${fromHandle}`;
153
- if (from?.bio)
154
- content += ` (${from.bio})`;
155
- if (from?.model)
156
- content += ` [${from.model}]`;
157
- content += `\n\nPreview: ${preview}`;
158
- content += `\n\nUse accept_request tool with request_id "${meta.request_id}" to accept.`;
159
- }
160
- else {
161
- meta.thread_id = data.threadId ?? '';
162
- meta.from_handle = fromHandle;
163
- content = `Message from @${fromHandle}: ${preview}`;
164
- }
165
141
  await server.notification({
166
142
  method: 'notifications/claude/channel',
167
- params: { content, meta },
143
+ params: {
144
+ content: data.content ?? '',
145
+ meta: data.meta ?? {},
146
+ },
168
147
  });
169
148
  }
170
149
  catch (err) {
@@ -172,52 +151,120 @@ async function pushNotification(data) {
172
151
  }
173
152
  }
174
153
  // ---------------------------------------------------------------------------
175
- // Inbox pollerchecks for unread messages periodically
176
- // SSE would be better but requires the agent to maintain a persistent
177
- // connection; polling is more reliable for a stdio channel subprocess.
154
+ // Welcome bannerfetches agent profile and pushes on first connect
178
155
  // ---------------------------------------------------------------------------
179
- let lastCheckedAt = new Date().toISOString();
180
- async function pollInbox() {
156
+ async function pushWelcome() {
181
157
  try {
182
- const res = await apiFetch('/messages/inbox?limit=10');
183
- if (!res.ok)
158
+ const res = await apiFetch('/me');
159
+ const meRes = res.ok
160
+ ? (await res.json())
161
+ : null;
162
+ const me = meRes?.agent ?? null;
163
+ const level = me?.level ?? 1;
164
+ const xp = me?.xp ?? 0;
165
+ const bar = '█'.repeat(Math.min(level, 10)) + '░'.repeat(Math.max(10 - level, 0));
166
+ const content = me
167
+ ? [
168
+ `✦ inErrata ━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
169
+ `┃ Welcome back, @${me.handle}`,
170
+ `┃ ⚡ Lv.${level} ✨ ${xp} XP ${bar}`,
171
+ `✦ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
172
+ ].join('\n')
173
+ : `✦ Connected to inErrata.`;
174
+ await server.notification({
175
+ method: 'notifications/claude/channel',
176
+ params: { content, meta: { type: 'welcome' } },
177
+ });
178
+ }
179
+ catch {
180
+ // Best-effort
181
+ }
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Announcement channel relay — connects to GET /mcp and relays notifications.
185
+ // Reconnects automatically with exponential backoff on stream drop.
186
+ // ---------------------------------------------------------------------------
187
+ let welcomed = false;
188
+ async function connectAnnouncementChannel(retryDelay = 1000) {
189
+ try {
190
+ // Step 1: initialize a new MCP session
191
+ const initRes = await fetch(`${MCP_BASE}/mcp`, {
192
+ method: 'POST',
193
+ headers: {
194
+ 'Content-Type': 'application/json',
195
+ Authorization: `Bearer ${API_KEY}`,
196
+ },
197
+ body: JSON.stringify({
198
+ jsonrpc: '2.0',
199
+ id: 1,
200
+ method: 'initialize',
201
+ params: {
202
+ protocolVersion: '2025-03-26',
203
+ capabilities: {},
204
+ clientInfo: { name: 'inerrata-channel-relay', version: '0.1.0' },
205
+ },
206
+ }),
207
+ });
208
+ const sessionId = initRes.headers.get('mcp-session-id');
209
+ if (!sessionId) {
210
+ console.error('[inerrata-channel] No session ID from MCP initialize — retrying in', retryDelay, 'ms');
211
+ setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
184
212
  return;
185
- const msgs = (await res.json());
186
- // Get pending requests too
187
- const reqRes = await apiFetch('/messages/requests');
188
- const requests = reqRes.ok
189
- ? (await reqRes.json())
190
- : [];
191
- // Push unread messages newer than last check, then mark as read so
192
- // subsequent polls (and other running instances) don't re-deliver.
193
- for (const msg of msgs) {
194
- if (!msg.read && msg.createdAt > lastCheckedAt) {
195
- await pushNotification({
196
- type: 'message.received',
197
- messageId: msg.id,
198
- threadId: msg.threadId,
199
- fromHandle: msg.fromHandle ?? msg.fromAgent,
200
- preview: msg.body.slice(0, 200),
201
- });
202
- apiFetch(`/messages/${msg.id}/read`, { method: 'PATCH' }).catch(() => { });
203
- }
204
213
  }
205
- // Push pending requests
206
- for (const req of requests) {
207
- if (req.createdAt > lastCheckedAt) {
208
- await pushNotification({
209
- type: 'message.request',
210
- requestId: req.id,
211
- from: req.from,
212
- preview: req.preview ?? '',
213
- });
214
+ // Step 2: open the announcement channel stream
215
+ const streamRes = await fetch(`${MCP_BASE}/mcp`, {
216
+ method: 'GET',
217
+ headers: {
218
+ Authorization: `Bearer ${API_KEY}`,
219
+ 'mcp-session-id': sessionId,
220
+ Accept: 'text/event-stream',
221
+ },
222
+ });
223
+ if (!streamRes.ok || !streamRes.body) {
224
+ console.error('[inerrata-channel] Failed to open announcement stream:', streamRes.status);
225
+ setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
226
+ return;
227
+ }
228
+ console.error('[inerrata-channel] Connected to announcement channel');
229
+ // Send welcome on first successful connection
230
+ if (!welcomed) {
231
+ welcomed = true;
232
+ pushWelcome().catch(() => { });
233
+ }
234
+ // Step 3: parse SSE stream line by line
235
+ const reader = streamRes.body.getReader();
236
+ const decoder = new TextDecoder();
237
+ let buffer = '';
238
+ while (true) {
239
+ const { done, value } = await reader.read();
240
+ if (done)
241
+ break;
242
+ buffer += decoder.decode(value, { stream: true });
243
+ const lines = buffer.split('\n');
244
+ buffer = lines.pop() ?? '';
245
+ for (const line of lines) {
246
+ if (!line.startsWith('data: '))
247
+ continue;
248
+ const raw = line.slice(6).trim();
249
+ if (!raw)
250
+ continue;
251
+ try {
252
+ const msg = JSON.parse(raw);
253
+ if (msg.method === 'notifications/claude/channel' && msg.params) {
254
+ await pushNotification(msg.params);
255
+ }
256
+ }
257
+ catch {
258
+ // Malformed line — skip
259
+ }
214
260
  }
215
261
  }
216
- lastCheckedAt = new Date().toISOString();
262
+ console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
217
263
  }
218
264
  catch (err) {
219
- console.error('[inerrata-channel] Poll error:', err);
265
+ console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
220
266
  }
267
+ setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
221
268
  }
222
269
  // ---------------------------------------------------------------------------
223
270
  // Start
@@ -225,46 +272,7 @@ async function pollInbox() {
225
272
  async function main() {
226
273
  const transport = new StdioServerTransport();
227
274
  await server.connect(transport);
228
- // Send welcome notification after handshake
229
- setTimeout(async () => {
230
- try {
231
- // Fetch agent profile for the welcome
232
- const res = await apiFetch('/me');
233
- const meRes = res.ok ? (await res.json()) : null;
234
- const me = meRes?.agent ?? null;
235
- const level = me?.level ?? 1;
236
- const xp = me?.xp ?? 0;
237
- const bar = '\u2588'.repeat(Math.min(level, 10)) + '\u2591'.repeat(Math.max(10 - level, 0));
238
- const content = me
239
- ? [
240
- ``,
241
- ` \u2726 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
242
- ` \u2503 inErrata`,
243
- ` \u2503`,
244
- ` \u2503 Welcome back, @${me.handle}`,
245
- ` \u2503 \u26A1 Lv.${level} \u2728 ${xp} XP ${bar}`,
246
- ` \u2503`,
247
- ` \u2503 Polling every ${POLL_INTERVAL_MS / 1000}s for messages.`,
248
- ` \u2503 Use reply tool to respond inline.`,
249
- ` \u2503`,
250
- ` \u2726 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`,
251
- ``,
252
- ].join('\n')
253
- : `\u2726 Connected to inErrata. Polling every ${POLL_INTERVAL_MS / 1000}s.`;
254
- await server.notification({
255
- method: 'notifications/claude/channel',
256
- params: { content, meta: { type: 'welcome' } },
257
- });
258
- }
259
- catch {
260
- // Ignore — welcome is best-effort
261
- }
262
- }, 2000);
263
- // Start polling loop
264
- setInterval(pollInbox, POLL_INTERVAL_MS);
265
- // Initial poll after a short delay (let MCP handshake complete)
266
- setTimeout(pollInbox, 3000);
267
- console.error('[inerrata-channel] Connected — polling every 15s');
275
+ connectAnnouncementChannel().catch(console.error);
268
276
  }
269
277
  main().catch((err) => {
270
278
  console.error('[inerrata-channel] Fatal:', err);
package/package.json CHANGED
@@ -1,30 +1,32 @@
1
- {
2
- "name": "@inerrata/channel",
3
- "version": "0.1.4",
4
- "description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
5
- "type": "module",
6
- "files": ["dist"],
7
- "bin": {
8
- "channel": "./dist/index.js",
9
- "inerrata-channel": "./dist/index.js",
10
- "errata-openclaw-bridge": "./dist/openclaw.js"
11
- },
12
- "scripts": {
13
- "build": "tsc",
14
- "typecheck": "tsc --noEmit",
15
- "dev": "tsx src/index.ts"
16
- },
17
- "dependencies": {
18
- "@modelcontextprotocol/sdk": "^1.27.1",
19
- "eventsource": "^3.0.0"
20
- },
21
- "publishConfig": {
22
- "access": "public",
23
- "registry": "https://registry.npmjs.org"
24
- },
25
- "devDependencies": {
26
- "@types/node": "^22.0.0",
27
- "typescript": "^5.8.0",
28
- "tsx": "^4.0.0"
29
- }
30
- }
1
+ {
2
+ "name": "@inerrata/channel",
3
+ "version": "0.1.8",
4
+ "description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "bin": {
10
+ "channel": "./dist/index.js",
11
+ "inerrata-channel": "./dist/index.js",
12
+ "errata-openclaw-bridge": "./dist/openclaw.js"
13
+ },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "typecheck": "tsc --noEmit",
17
+ "dev": "tsx src/index.ts"
18
+ },
19
+ "dependencies": {
20
+ "@modelcontextprotocol/sdk": "^1.27.1",
21
+ "eventsource": "^3.0.0"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.0.0",
29
+ "typescript": "^5.8.0",
30
+ "tsx": "^4.0.0"
31
+ }
32
+ }