@inerrata/channel 0.1.9 → 0.3.0
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 +95 -23
- package/package.json +32 -32
package/dist/index.js
CHANGED
|
@@ -185,6 +185,11 @@ async function pushWelcome() {
|
|
|
185
185
|
// Reconnects automatically with exponential backoff on stream drop.
|
|
186
186
|
// ---------------------------------------------------------------------------
|
|
187
187
|
let welcomed = false;
|
|
188
|
+
// How long to wait with zero data before assuming the stream is dead
|
|
189
|
+
// and reconnecting proactively. Fly.io's proxy kills idle connections
|
|
190
|
+
// after ~60s, so we set this well above a heartbeat interval but below
|
|
191
|
+
// the point where we'd miss many notifications.
|
|
192
|
+
const STREAM_IDLE_TIMEOUT_MS = 90_000;
|
|
188
193
|
async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
189
194
|
try {
|
|
190
195
|
// Step 1: initialize a new MCP session
|
|
@@ -192,6 +197,7 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
192
197
|
method: 'POST',
|
|
193
198
|
headers: {
|
|
194
199
|
'Content-Type': 'application/json',
|
|
200
|
+
'Accept': 'application/json, text/event-stream',
|
|
195
201
|
Authorization: `Bearer ${API_KEY}`,
|
|
196
202
|
},
|
|
197
203
|
body: JSON.stringify({
|
|
@@ -225,9 +231,24 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
225
231
|
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
226
232
|
return;
|
|
227
233
|
}
|
|
228
|
-
console.error('[inerrata-channel] Connected to announcement channel');
|
|
229
|
-
//
|
|
230
|
-
|
|
234
|
+
console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
|
|
235
|
+
// Reset backoff on successful connection
|
|
236
|
+
retryDelay = 1000;
|
|
237
|
+
// Send notifications/initialized (MCP protocol requirement) and push
|
|
238
|
+
// welcome in a single fire-and-forget POST — the initialized notification
|
|
239
|
+
// tells the server the session is ready, and the welcome greets the user.
|
|
240
|
+
fetch(`${MCP_BASE}/mcp`, {
|
|
241
|
+
method: 'POST',
|
|
242
|
+
headers: {
|
|
243
|
+
'Content-Type': 'application/json',
|
|
244
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
245
|
+
'mcp-session-id': sessionId,
|
|
246
|
+
},
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
jsonrpc: '2.0',
|
|
249
|
+
method: 'notifications/initialized',
|
|
250
|
+
}),
|
|
251
|
+
}).catch(() => { });
|
|
231
252
|
if (!welcomed) {
|
|
232
253
|
welcomed = true;
|
|
233
254
|
setTimeout(() => pushWelcome().catch(() => { }), 500);
|
|
@@ -236,30 +257,47 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
236
257
|
const reader = streamRes.body.getReader();
|
|
237
258
|
const decoder = new TextDecoder();
|
|
238
259
|
let buffer = '';
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
260
|
+
let lastDataAt = Date.now();
|
|
261
|
+
// Idle watchdog — if no data (including heartbeats) arrives within the
|
|
262
|
+
// timeout, the stream is likely dead (proxy killed it). Force reconnect.
|
|
263
|
+
const idleTimer = setInterval(() => {
|
|
264
|
+
if (Date.now() - lastDataAt > STREAM_IDLE_TIMEOUT_MS) {
|
|
265
|
+
console.error('[inerrata-channel] Stream idle for', STREAM_IDLE_TIMEOUT_MS, 'ms — forcing reconnect');
|
|
266
|
+
reader.cancel().catch(() => { });
|
|
267
|
+
clearInterval(idleTimer);
|
|
268
|
+
}
|
|
269
|
+
}, 10_000);
|
|
270
|
+
try {
|
|
271
|
+
while (true) {
|
|
272
|
+
const { done, value } = await reader.read();
|
|
273
|
+
if (done)
|
|
274
|
+
break;
|
|
275
|
+
lastDataAt = Date.now();
|
|
276
|
+
buffer += decoder.decode(value, { stream: true });
|
|
277
|
+
const lines = buffer.split('\n');
|
|
278
|
+
buffer = lines.pop() ?? '';
|
|
279
|
+
for (const line of lines) {
|
|
280
|
+
if (!line.startsWith('data: '))
|
|
281
|
+
continue;
|
|
282
|
+
const raw = line.slice(6).trim();
|
|
283
|
+
if (!raw)
|
|
284
|
+
continue;
|
|
285
|
+
try {
|
|
286
|
+
const msg = JSON.parse(raw);
|
|
287
|
+
if (msg.method === 'notifications/claude/channel' && msg.params) {
|
|
288
|
+
console.error('[inerrata-channel] Relaying notification:', msg.params.meta);
|
|
289
|
+
await pushNotification(msg.params);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Malformed line — skip
|
|
256
294
|
}
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
// Malformed line — skip
|
|
260
295
|
}
|
|
261
296
|
}
|
|
262
297
|
}
|
|
298
|
+
finally {
|
|
299
|
+
clearInterval(idleTimer);
|
|
300
|
+
}
|
|
263
301
|
console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
|
|
264
302
|
}
|
|
265
303
|
catch (err) {
|
|
@@ -268,13 +306,47 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
268
306
|
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
269
307
|
}
|
|
270
308
|
// ---------------------------------------------------------------------------
|
|
309
|
+
// Channel heartbeat — tells the server this agent's channel plugin is alive.
|
|
310
|
+
// The server uses this to fire online/offline status notifications to the
|
|
311
|
+
// agent's confirmed connections.
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
const HEARTBEAT_INTERVAL_MS = 60_000;
|
|
314
|
+
let heartbeatTimer;
|
|
315
|
+
function startHeartbeat() {
|
|
316
|
+
// Fire immediately, then repeat
|
|
317
|
+
sendHeartbeat();
|
|
318
|
+
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
319
|
+
}
|
|
320
|
+
function sendHeartbeat() {
|
|
321
|
+
apiFetch('/channel/heartbeat', { method: 'POST' }).catch((err) => {
|
|
322
|
+
console.error('[inerrata-channel] Heartbeat failed:', err);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function sendOffline() {
|
|
326
|
+
// Best-effort — process may be exiting
|
|
327
|
+
apiFetch('/channel/heartbeat', { method: 'DELETE' }).catch(() => { });
|
|
328
|
+
}
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
271
330
|
// Start
|
|
272
331
|
// ---------------------------------------------------------------------------
|
|
273
332
|
async function main() {
|
|
274
333
|
const transport = new StdioServerTransport();
|
|
275
334
|
await server.connect(transport);
|
|
335
|
+
startHeartbeat();
|
|
276
336
|
connectAnnouncementChannel().catch(console.error);
|
|
277
337
|
}
|
|
338
|
+
// Graceful shutdown — tell the server we're going offline
|
|
339
|
+
function shutdown() {
|
|
340
|
+
if (heartbeatTimer)
|
|
341
|
+
clearInterval(heartbeatTimer);
|
|
342
|
+
sendOffline();
|
|
343
|
+
// Give the offline request a moment to fire, then exit
|
|
344
|
+
setTimeout(() => process.exit(0), 500);
|
|
345
|
+
}
|
|
346
|
+
process.on('SIGINT', shutdown);
|
|
347
|
+
process.on('SIGTERM', shutdown);
|
|
348
|
+
// Windows: stdin close means parent (Claude Code) exited
|
|
349
|
+
process.stdin.on('end', shutdown);
|
|
278
350
|
main().catch((err) => {
|
|
279
351
|
console.error('[inerrata-channel] Fatal:', err);
|
|
280
352
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@inerrata/channel",
|
|
3
|
-
"version": "0.
|
|
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.3.0",
|
|
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
|
+
}
|