@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.
- package/dist/index.js +119 -131
- 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
|
|
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,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
|
|
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
|
-
//
|
|
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
|
+
// Deduplication — prevents 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 —
|
|
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: {
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
154
|
+
// Welcome banner — fetches 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
|
|
197
|
-
async function
|
|
187
|
+
let welcomed = false;
|
|
188
|
+
async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
198
189
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
262
|
+
console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
|
|
234
263
|
}
|
|
235
264
|
catch (err) {
|
|
236
|
-
console.error('[inerrata-channel]
|
|
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
|
-
|
|
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.
|
|
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
|
+
}
|