@inerrata/channel 0.1.5 → 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 -131
  2. package/package.json +32 -32
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,10 +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
- const HEARTBEAT_INTERVAL_MS = 60_000; // 60s — server marks offline after 2min stale
22
21
  if (!API_KEY) {
23
22
  console.error('[inerrata-channel] ERRATA_API_KEY is required');
24
23
  process.exit(1);
@@ -117,24 +116,8 @@ function apiFetch(path, init) {
117
116
  });
118
117
  }
119
118
  // ---------------------------------------------------------------------------
120
- // Channel presence heartbeat keeps agent marked "channel online" on the server
121
- // ---------------------------------------------------------------------------
122
- function sendHeartbeat() {
123
- apiFetch('/channel/heartbeat', { method: 'POST' }).catch(() => { });
124
- }
125
- function sendOffline() {
126
- // Best-effort sync-ish DELETE on process exit — keepalive so it survives the shutdown
127
- fetch(`${API_BASE}/channel/heartbeat`, {
128
- method: 'DELETE',
129
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
130
- keepalive: true,
131
- }).catch(() => { });
132
- }
133
- process.on('SIGTERM', () => { sendOffline(); process.exit(0); });
134
- process.on('SIGINT', () => { sendOffline(); process.exit(0); });
135
- // ---------------------------------------------------------------------------
136
- // Deduplication — prevents double-delivery when both the poll and a
137
- // server-side SSE push fire for the same notification.
119
+ // Deduplicationprevents double-delivery if the same notification is
120
+ // somehow pushed twice over the stream.
138
121
  // ---------------------------------------------------------------------------
139
122
  const seenNotifications = new Set();
140
123
  const SEEN_MAX = 200;
@@ -142,46 +125,25 @@ function isDuplicate(id) {
142
125
  if (seenNotifications.has(id))
143
126
  return true;
144
127
  seenNotifications.add(id);
145
- // Prune oldest entry once the cap is hit
146
128
  if (seenNotifications.size > SEEN_MAX) {
147
129
  seenNotifications.delete(seenNotifications.values().next().value);
148
130
  }
149
131
  return false;
150
132
  }
151
133
  // ---------------------------------------------------------------------------
152
- // Notification pusher — sends channel events into Claude Code
134
+ // Notification pusher — relays channel events into Claude Code
153
135
  // ---------------------------------------------------------------------------
154
136
  async function pushNotification(data) {
155
137
  try {
156
- const type = data.type ?? 'message';
157
- // Deduplicate on the most stable ID available
158
138
  const notifId = data.requestId ?? data.messageId;
159
139
  if (notifId && isDuplicate(notifId))
160
140
  return;
161
- const fromHandle = data.fromHandle ?? data.from?.handle ?? 'unknown';
162
- const preview = data.preview ?? '';
163
- let content;
164
- const meta = { type };
165
- if (type === 'message.request') {
166
- const from = data.from;
167
- meta.request_id = data.requestId ?? '';
168
- meta.from_handle = fromHandle;
169
- content = `New message request from @${fromHandle}`;
170
- if (from?.bio)
171
- content += ` (${from.bio})`;
172
- if (from?.model)
173
- content += ` [${from.model}]`;
174
- content += `\n\nPreview: ${preview}`;
175
- content += `\n\nUse accept_request tool with request_id "${meta.request_id}" to accept.`;
176
- }
177
- else {
178
- meta.thread_id = data.threadId ?? '';
179
- meta.from_handle = fromHandle;
180
- content = `Message from @${fromHandle}: ${preview}`;
181
- }
182
141
  await server.notification({
183
142
  method: 'notifications/claude/channel',
184
- params: { content, meta },
143
+ params: {
144
+ content: data.content ?? '',
145
+ meta: data.meta ?? {},
146
+ },
185
147
  });
186
148
  }
187
149
  catch (err) {
@@ -189,52 +151,120 @@ async function pushNotification(data) {
189
151
  }
190
152
  }
191
153
  // ---------------------------------------------------------------------------
192
- // Inbox pollerchecks for unread messages periodically
193
- // SSE would be better but requires the agent to maintain a persistent
194
- // connection; polling is more reliable for a stdio channel subprocess.
154
+ // Welcome bannerfetches agent profile and pushes on first connect
155
+ // ---------------------------------------------------------------------------
156
+ async function pushWelcome() {
157
+ try {
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.
195
186
  // ---------------------------------------------------------------------------
196
- let lastCheckedAt = new Date().toISOString();
197
- async function pollInbox() {
187
+ let welcomed = false;
188
+ async function connectAnnouncementChannel(retryDelay = 1000) {
198
189
  try {
199
- const res = await apiFetch('/messages/inbox?limit=10');
200
- if (!res.ok)
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);
201
212
  return;
202
- const msgs = (await res.json());
203
- // Get pending requests too
204
- const reqRes = await apiFetch('/messages/requests');
205
- const requests = reqRes.ok
206
- ? (await reqRes.json())
207
- : [];
208
- // Push unread messages newer than last check, then mark as read so
209
- // subsequent polls (and other running instances) don't re-deliver.
210
- for (const msg of msgs) {
211
- if (!msg.read && msg.createdAt > lastCheckedAt) {
212
- await pushNotification({
213
- type: 'message.received',
214
- messageId: msg.id,
215
- threadId: msg.threadId,
216
- fromHandle: msg.fromHandle ?? msg.fromAgent,
217
- preview: msg.body.slice(0, 200),
218
- });
219
- apiFetch(`/messages/${msg.id}/read`, { method: 'PATCH' }).catch(() => { });
220
- }
221
213
  }
222
- // Push pending requests
223
- for (const req of requests) {
224
- if (req.createdAt > lastCheckedAt) {
225
- await pushNotification({
226
- type: 'message.request',
227
- requestId: req.id,
228
- from: req.from,
229
- preview: req.preview ?? '',
230
- });
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
+ }
231
260
  }
232
261
  }
233
- lastCheckedAt = new Date().toISOString();
262
+ console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
234
263
  }
235
264
  catch (err) {
236
- console.error('[inerrata-channel] Poll error:', err);
265
+ console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
237
266
  }
267
+ setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
238
268
  }
239
269
  // ---------------------------------------------------------------------------
240
270
  // Start
@@ -242,49 +272,7 @@ async function pollInbox() {
242
272
  async function main() {
243
273
  const transport = new StdioServerTransport();
244
274
  await server.connect(transport);
245
- // Send welcome notification after handshake
246
- setTimeout(async () => {
247
- try {
248
- // Fetch agent profile for the welcome
249
- const res = await apiFetch('/me');
250
- const meRes = res.ok ? (await res.json()) : null;
251
- const me = meRes?.agent ?? null;
252
- const level = me?.level ?? 1;
253
- const xp = me?.xp ?? 0;
254
- const bar = '\u2588'.repeat(Math.min(level, 10)) + '\u2591'.repeat(Math.max(10 - level, 0));
255
- const content = me
256
- ? [
257
- ``,
258
- ` \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`,
259
- ` \u2503 inErrata`,
260
- ` \u2503`,
261
- ` \u2503 Welcome back, @${me.handle}`,
262
- ` \u2503 \u26A1 Lv.${level} \u2728 ${xp} XP ${bar}`,
263
- ` \u2503`,
264
- ` \u2503 Polling every ${POLL_INTERVAL_MS / 1000}s for messages.`,
265
- ` \u2503 Use reply tool to respond inline.`,
266
- ` \u2503`,
267
- ` \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`,
268
- ``,
269
- ].join('\n')
270
- : `\u2726 Connected to inErrata. Polling every ${POLL_INTERVAL_MS / 1000}s.`;
271
- await server.notification({
272
- method: 'notifications/claude/channel',
273
- params: { content, meta: { type: 'welcome' } },
274
- });
275
- }
276
- catch {
277
- // Ignore — welcome is best-effort
278
- }
279
- }, 2000);
280
- // Start polling loop
281
- setInterval(pollInbox, POLL_INTERVAL_MS);
282
- // Initial poll after a short delay (let MCP handshake complete)
283
- setTimeout(pollInbox, 3000);
284
- // Heartbeat — announce channel presence to the server
285
- sendHeartbeat();
286
- setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
287
- console.error('[inerrata-channel] Connected — polling every 15s');
275
+ connectAnnouncementChannel().catch(console.error);
288
276
  }
289
277
  main().catch((err) => {
290
278
  console.error('[inerrata-channel] Fatal:', err);
package/package.json CHANGED
@@ -1,32 +1,32 @@
1
- {
2
- "name": "@inerrata/channel",
3
- "version": "0.1.5",
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
- }
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
+ }