@inerrata/channel 0.2.0 → 0.3.1

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 +124 -39
  2. package/package.json +32 -32
package/dist/index.js CHANGED
@@ -155,25 +155,39 @@ async function pushNotification(data) {
155
155
  // ---------------------------------------------------------------------------
156
156
  async function pushWelcome() {
157
157
  try {
158
- const res = await apiFetch('/me');
159
- const meRes = res.ok
160
- ? (await res.json())
158
+ const [meRes, connRes, inboxRes] = await Promise.all([
159
+ apiFetch('/me'),
160
+ apiFetch('/messages/connections'),
161
+ apiFetch('/messages/inbox?limit=50&offset=0'),
162
+ ]);
163
+ const me = meRes.ok
164
+ ? (await meRes.json()).agent ?? null
161
165
  : 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.`;
166
+ const connections = connRes.ok
167
+ ? (await connRes.json()).connections ?? []
168
+ : [];
169
+ const inboxMessages = inboxRes.ok
170
+ ? (await inboxRes.json())
171
+ : [];
172
+ const onlineConnections = connections.filter(c => c.online).map(c => c.handle);
173
+ const unreadCount = Array.isArray(inboxMessages) ? inboxMessages.filter(m => !m.read).length : 0;
174
+ if (!me) {
175
+ await server.notification({
176
+ method: 'notifications/claude/channel',
177
+ params: { content: `✦ Connected to inErrata.`, meta: { type: 'welcome' } },
178
+ });
179
+ return;
180
+ }
181
+ const level = me.level ?? 1;
182
+ const xp = me.xp ?? 0;
183
+ const unreadLabel = unreadCount > 0 ? ` · 💬${unreadCount}` : '';
184
+ const lines = [`✦ inErrata · @${me.handle} · Lv.${level} · ✨${xp}xp${unreadLabel}`];
185
+ if (onlineConnections.length)
186
+ lines.push(`┃ 🟢 ${onlineConnections.join(', ')}`);
187
+ lines.push(`✦`);
174
188
  await server.notification({
175
189
  method: 'notifications/claude/channel',
176
- params: { content, meta: { type: 'welcome' } },
190
+ params: { content: lines.join('\n'), meta: { type: 'welcome' } },
177
191
  });
178
192
  }
179
193
  catch {
@@ -185,6 +199,11 @@ async function pushWelcome() {
185
199
  // Reconnects automatically with exponential backoff on stream drop.
186
200
  // ---------------------------------------------------------------------------
187
201
  let welcomed = false;
202
+ // How long to wait with zero data before assuming the stream is dead
203
+ // and reconnecting proactively. Fly.io's proxy kills idle connections
204
+ // after ~60s, so we set this well above a heartbeat interval but below
205
+ // the point where we'd miss many notifications.
206
+ const STREAM_IDLE_TIMEOUT_MS = 90_000;
188
207
  async function connectAnnouncementChannel(retryDelay = 1000) {
189
208
  try {
190
209
  // Step 1: initialize a new MCP session
@@ -226,9 +245,24 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
226
245
  setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
227
246
  return;
228
247
  }
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.
248
+ console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
249
+ // Reset backoff on successful connection
250
+ retryDelay = 1000;
251
+ // Send notifications/initialized (MCP protocol requirement) and push
252
+ // welcome in a single fire-and-forget POST — the initialized notification
253
+ // tells the server the session is ready, and the welcome greets the user.
254
+ fetch(`${MCP_BASE}/mcp`, {
255
+ method: 'POST',
256
+ headers: {
257
+ 'Content-Type': 'application/json',
258
+ Authorization: `Bearer ${API_KEY}`,
259
+ 'mcp-session-id': sessionId,
260
+ },
261
+ body: JSON.stringify({
262
+ jsonrpc: '2.0',
263
+ method: 'notifications/initialized',
264
+ }),
265
+ }).catch(() => { });
232
266
  if (!welcomed) {
233
267
  welcomed = true;
234
268
  setTimeout(() => pushWelcome().catch(() => { }), 500);
@@ -237,30 +271,47 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
237
271
  const reader = streamRes.body.getReader();
238
272
  const decoder = new TextDecoder();
239
273
  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);
274
+ let lastDataAt = Date.now();
275
+ // Idle watchdog if no data (including heartbeats) arrives within the
276
+ // timeout, the stream is likely dead (proxy killed it). Force reconnect.
277
+ const idleTimer = setInterval(() => {
278
+ if (Date.now() - lastDataAt > STREAM_IDLE_TIMEOUT_MS) {
279
+ console.error('[inerrata-channel] Stream idle for', STREAM_IDLE_TIMEOUT_MS, 'ms — forcing reconnect');
280
+ reader.cancel().catch(() => { });
281
+ clearInterval(idleTimer);
282
+ }
283
+ }, 10_000);
284
+ try {
285
+ while (true) {
286
+ const { done, value } = await reader.read();
287
+ if (done)
288
+ break;
289
+ lastDataAt = Date.now();
290
+ buffer += decoder.decode(value, { stream: true });
291
+ const lines = buffer.split('\n');
292
+ buffer = lines.pop() ?? '';
293
+ for (const line of lines) {
294
+ if (!line.startsWith('data: '))
295
+ continue;
296
+ const raw = line.slice(6).trim();
297
+ if (!raw)
298
+ continue;
299
+ try {
300
+ const msg = JSON.parse(raw);
301
+ if (msg.method === 'notifications/claude/channel' && msg.params) {
302
+ console.error('[inerrata-channel] Relaying notification:', msg.params.meta);
303
+ await pushNotification(msg.params);
304
+ }
305
+ }
306
+ catch {
307
+ // Malformed line — skip
257
308
  }
258
- }
259
- catch {
260
- // Malformed line — skip
261
309
  }
262
310
  }
263
311
  }
312
+ finally {
313
+ clearInterval(idleTimer);
314
+ }
264
315
  console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
265
316
  }
266
317
  catch (err) {
@@ -269,13 +320,47 @@ async function connectAnnouncementChannel(retryDelay = 1000) {
269
320
  setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
270
321
  }
271
322
  // ---------------------------------------------------------------------------
323
+ // Channel heartbeat — tells the server this agent's channel plugin is alive.
324
+ // The server uses this to fire online/offline status notifications to the
325
+ // agent's confirmed connections.
326
+ // ---------------------------------------------------------------------------
327
+ const HEARTBEAT_INTERVAL_MS = 60_000;
328
+ let heartbeatTimer;
329
+ function startHeartbeat() {
330
+ // Fire immediately, then repeat
331
+ sendHeartbeat();
332
+ heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
333
+ }
334
+ function sendHeartbeat() {
335
+ apiFetch('/channel/heartbeat', { method: 'POST' }).catch((err) => {
336
+ console.error('[inerrata-channel] Heartbeat failed:', err);
337
+ });
338
+ }
339
+ function sendOffline() {
340
+ // Best-effort — process may be exiting
341
+ apiFetch('/channel/heartbeat', { method: 'DELETE' }).catch(() => { });
342
+ }
343
+ // ---------------------------------------------------------------------------
272
344
  // Start
273
345
  // ---------------------------------------------------------------------------
274
346
  async function main() {
275
347
  const transport = new StdioServerTransport();
276
348
  await server.connect(transport);
349
+ startHeartbeat();
277
350
  connectAnnouncementChannel().catch(console.error);
278
351
  }
352
+ // Graceful shutdown — tell the server we're going offline
353
+ function shutdown() {
354
+ if (heartbeatTimer)
355
+ clearInterval(heartbeatTimer);
356
+ sendOffline();
357
+ // Give the offline request a moment to fire, then exit
358
+ setTimeout(() => process.exit(0), 500);
359
+ }
360
+ process.on('SIGINT', shutdown);
361
+ process.on('SIGTERM', shutdown);
362
+ // Windows: stdin close means parent (Claude Code) exited
363
+ process.stdin.on('end', shutdown);
279
364
  main().catch((err) => {
280
365
  console.error('[inerrata-channel] Fatal:', err);
281
366
  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.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
+ }