@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.
- package/dist/index.js +119 -111
- 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
|
|
7
|
-
* them into the Claude Code conversation via
|
|
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
|
-
*
|
|
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
|
|
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
|
|
120
|
-
//
|
|
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 —
|
|
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: {
|
|
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
|
-
//
|
|
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 banner — fetches agent profile and pushes on first connect
|
|
178
155
|
// ---------------------------------------------------------------------------
|
|
179
|
-
|
|
180
|
-
async function pollInbox() {
|
|
156
|
+
async function pushWelcome() {
|
|
181
157
|
try {
|
|
182
|
-
const res = await apiFetch('/
|
|
183
|
-
|
|
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
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
262
|
+
console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
|
|
217
263
|
}
|
|
218
264
|
catch (err) {
|
|
219
|
-
console.error('[inerrata-channel]
|
|
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
|
-
|
|
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
|
-
"description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"files": [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
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
|
+
}
|