@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.
Files changed (2) hide show
  1. package/dist/index.js +94 -23
  2. 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
- // Send welcome on first successful connection.
231
- // Delayed slightly so Claude Code's notification handler is ready before we push.
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
- while (true) {
241
- const { done, value } = await reader.read();
242
- if (done)
243
- break;
244
- buffer += decoder.decode(value, { stream: true });
245
- const lines = buffer.split('\n');
246
- buffer = lines.pop() ?? '';
247
- for (const line of lines) {
248
- if (!line.startsWith('data: '))
249
- continue;
250
- const raw = line.slice(6).trim();
251
- if (!raw)
252
- continue;
253
- try {
254
- const msg = JSON.parse(raw);
255
- if (msg.method === 'notifications/claude/channel' && msg.params) {
256
- await pushNotification(msg.params);
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.2.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
+ }