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