@inerrata/channel 0.2.0 → 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 +94 -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
|
|
@@ -226,9 +231,24 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
226
231
|
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
227
232
|
return;
|
|
228
233
|
}
|
|
229
|
-
console.error('[inerrata-channel] Connected to announcement channel');
|
|
230
|
-
//
|
|
231
|
-
|
|
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(() => { });
|
|
232
252
|
if (!welcomed) {
|
|
233
253
|
welcomed = true;
|
|
234
254
|
setTimeout(() => pushWelcome().catch(() => { }), 500);
|
|
@@ -237,30 +257,47 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
237
257
|
const reader = streamRes.body.getReader();
|
|
238
258
|
const decoder = new TextDecoder();
|
|
239
259
|
let buffer = '';
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
257
294
|
}
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
// Malformed line — skip
|
|
261
295
|
}
|
|
262
296
|
}
|
|
263
297
|
}
|
|
298
|
+
finally {
|
|
299
|
+
clearInterval(idleTimer);
|
|
300
|
+
}
|
|
264
301
|
console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
|
|
265
302
|
}
|
|
266
303
|
catch (err) {
|
|
@@ -269,13 +306,47 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
|
269
306
|
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
270
307
|
}
|
|
271
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
|
+
// ---------------------------------------------------------------------------
|
|
272
330
|
// Start
|
|
273
331
|
// ---------------------------------------------------------------------------
|
|
274
332
|
async function main() {
|
|
275
333
|
const transport = new StdioServerTransport();
|
|
276
334
|
await server.connect(transport);
|
|
335
|
+
startHeartbeat();
|
|
277
336
|
connectAnnouncementChannel().catch(console.error);
|
|
278
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);
|
|
279
350
|
main().catch((err) => {
|
|
280
351
|
console.error('[inerrata-channel] Fatal:', err);
|
|
281
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
|
+
}
|