@inerrata/channel 0.3.6 → 0.3.8
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/api-client.d.ts +14 -0
- package/dist/api-client.js +96 -0
- package/dist/cache.d.ts +64 -0
- package/dist/cache.js +169 -0
- package/dist/heartbeat.d.ts +3 -0
- package/dist/heartbeat.js +30 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +7 -363
- package/dist/mcp-server.d.ts +39 -0
- package/dist/mcp-server.js +124 -0
- package/dist/notification-buffer.d.ts +18 -0
- package/dist/notification-buffer.js +118 -0
- package/dist/openclaw.js +1 -1
- package/dist/prompts.d.ts +1 -0
- package/dist/prompts.js +70 -0
- package/dist/stream-relay.d.ts +4 -0
- package/dist/stream-relay.js +361 -0
- package/package.json +5 -3
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification buffer — deduplication, queueing, summary, and drain logic
|
|
3
|
+
* for inErrata channel notifications.
|
|
4
|
+
*/
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Buffer state
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
const notificationBuffer = [];
|
|
9
|
+
const NOTIFICATION_BUFFER_MAX = 200;
|
|
10
|
+
let generatedNotificationCounter = 0;
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Deduplication — prevents double-delivery if the same notification is
|
|
13
|
+
// somehow pushed twice over the stream.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
const seenNotifications = new Set();
|
|
16
|
+
const SEEN_MAX = 200;
|
|
17
|
+
export function isDuplicate(id) {
|
|
18
|
+
if (seenNotifications.has(id))
|
|
19
|
+
return true;
|
|
20
|
+
seenNotifications.add(id);
|
|
21
|
+
if (seenNotifications.size > SEEN_MAX) {
|
|
22
|
+
seenNotifications.delete(seenNotifications.values().next().value);
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Queue / read / mark
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export function queueNotification(data) {
|
|
30
|
+
const meta = (data.meta ?? {});
|
|
31
|
+
const type = meta.type ?? 'message';
|
|
32
|
+
if (type === 'welcome')
|
|
33
|
+
return;
|
|
34
|
+
const explicitId = data.requestId ?? data.messageId;
|
|
35
|
+
const id = explicitId || `${type}:${Date.now()}:${generatedNotificationCounter++}`;
|
|
36
|
+
if (notificationBuffer.some(notification => notification.id === id))
|
|
37
|
+
return;
|
|
38
|
+
notificationBuffer.push({
|
|
39
|
+
id,
|
|
40
|
+
type,
|
|
41
|
+
content: (data.content ?? '').trim(),
|
|
42
|
+
meta,
|
|
43
|
+
createdAt: new Date().toISOString(),
|
|
44
|
+
read: false,
|
|
45
|
+
});
|
|
46
|
+
if (notificationBuffer.length > NOTIFICATION_BUFFER_MAX) {
|
|
47
|
+
notificationBuffer.splice(0, notificationBuffer.length - NOTIFICATION_BUFFER_MAX);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function markNotificationRead(notificationId) {
|
|
51
|
+
const notification = notificationBuffer.find(item => item.id === notificationId);
|
|
52
|
+
if (notification)
|
|
53
|
+
notification.read = true;
|
|
54
|
+
}
|
|
55
|
+
export function getUnreadNotifications() {
|
|
56
|
+
return notificationBuffer.filter(notification => !notification.read);
|
|
57
|
+
}
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Summary / drain
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
export function buildNotificationSummary() {
|
|
62
|
+
const unread = getUnreadNotifications();
|
|
63
|
+
if (!unread.length) {
|
|
64
|
+
return 'No unread inErrata notifications.';
|
|
65
|
+
}
|
|
66
|
+
const counts = {
|
|
67
|
+
requests: unread.filter(notification => notification.type === 'message.request').length,
|
|
68
|
+
messages: unread.filter(notification => notification.type === 'message.received').length,
|
|
69
|
+
presence: unread.filter(notification => notification.type === 'agent.online' || notification.type === 'agent.offline').length,
|
|
70
|
+
tasks: unread.filter(notification => notification.type === 'task.started' || notification.type === 'task.completed').length,
|
|
71
|
+
};
|
|
72
|
+
const other = unread.length - counts.requests - counts.messages - counts.presence - counts.tasks;
|
|
73
|
+
const recent = unread.slice(-5);
|
|
74
|
+
const lines = [
|
|
75
|
+
`Unread inErrata notifications: ${unread.length}`,
|
|
76
|
+
`- Message requests: ${counts.requests}`,
|
|
77
|
+
`- Direct messages: ${counts.messages}`,
|
|
78
|
+
`- Presence changes: ${counts.presence}`,
|
|
79
|
+
`- Task updates: ${counts.tasks}`,
|
|
80
|
+
`- Other: ${other < 0 ? 0 : other}`,
|
|
81
|
+
'',
|
|
82
|
+
'Most recent unread notifications:',
|
|
83
|
+
];
|
|
84
|
+
recent.forEach((notification, index) => {
|
|
85
|
+
lines.push(`${index + 1}. ${formatNotification(notification)}`);
|
|
86
|
+
});
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
export function drainNotifications(limit, markRead) {
|
|
90
|
+
const unread = getUnreadNotifications().slice(0, limit);
|
|
91
|
+
if (!unread.length) {
|
|
92
|
+
return 'No unread inErrata notifications to drain.';
|
|
93
|
+
}
|
|
94
|
+
if (markRead) {
|
|
95
|
+
unread.forEach(notification => {
|
|
96
|
+
notification.read = true;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
const remaining = getUnreadNotifications().length;
|
|
100
|
+
const lines = [
|
|
101
|
+
`Returning ${unread.length} unread inErrata notification${unread.length === 1 ? '' : 's'}.`,
|
|
102
|
+
markRead ? `Marked returned notifications as read. ${remaining} unread remain.` : `Left returned notifications unread. ${remaining} unread remain.`,
|
|
103
|
+
'',
|
|
104
|
+
];
|
|
105
|
+
unread.forEach((notification, index) => {
|
|
106
|
+
lines.push(`${index + 1}. ${formatNotification(notification)}`);
|
|
107
|
+
if (notification.content) {
|
|
108
|
+
lines.push(notification.content);
|
|
109
|
+
}
|
|
110
|
+
lines.push('');
|
|
111
|
+
});
|
|
112
|
+
return lines.join('\n').trim();
|
|
113
|
+
}
|
|
114
|
+
function formatNotification(notification) {
|
|
115
|
+
const actor = notification.meta.from_handle || notification.meta.handle || 'unknown';
|
|
116
|
+
const actorPrefix = actor === 'unknown' ? '' : ` from @${actor}`;
|
|
117
|
+
return `[${notification.type}]${actorPrefix} at ${notification.createdAt}`;
|
|
118
|
+
}
|
package/dist/openclaw.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* npx @inerrata/channel openclaw
|
|
13
13
|
*
|
|
14
14
|
* Then register the bridge URL as an inErrata webhook:
|
|
15
|
-
* curl -X POST https://inerrata.
|
|
15
|
+
* curl -X POST https://inerrata-production.up.railway.app/api/v1/webhooks \
|
|
16
16
|
* -H "Authorization: Bearer err_your_key" \
|
|
17
17
|
* -H "Content-Type: application/json" \
|
|
18
18
|
* -d '{"url":"http://your-bridge:7890/errata","events":["message.received","message.request"],"secret":"your-secret"}'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP prompt handlers for the inErrata channel plugin.
|
|
3
|
+
*/
|
|
4
|
+
import { ListPromptsRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { server } from './mcp-server.js';
|
|
6
|
+
import { apiFetch } from './api-client.js';
|
|
7
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({
|
|
8
|
+
prompts: [
|
|
9
|
+
{
|
|
10
|
+
name: 'pending-requests',
|
|
11
|
+
description: 'Review pending inErrata message requests and accept or decline each one',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'triage-notifications',
|
|
15
|
+
description: 'Check buffered inErrata notifications, summarize what matters, and respond if needed',
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
}));
|
|
19
|
+
server.setRequestHandler(GetPromptRequestSchema, async (req) => {
|
|
20
|
+
if (req.params.name === 'triage-notifications') {
|
|
21
|
+
return {
|
|
22
|
+
messages: [{
|
|
23
|
+
role: 'user',
|
|
24
|
+
content: {
|
|
25
|
+
type: 'text',
|
|
26
|
+
text: [
|
|
27
|
+
'Check your buffered inErrata notifications before continuing.',
|
|
28
|
+
'First call notification_summary.',
|
|
29
|
+
'If there are unread notifications, call drain_notifications.',
|
|
30
|
+
'Summarize what is new, prioritize message requests and direct messages, and use send_message or message_request when appropriate.',
|
|
31
|
+
].join('\n'),
|
|
32
|
+
},
|
|
33
|
+
}],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (req.params.name !== 'pending-requests') {
|
|
37
|
+
throw new Error(`Unknown prompt: ${req.params.name}`);
|
|
38
|
+
}
|
|
39
|
+
const res = await apiFetch('/messages/requests');
|
|
40
|
+
const requests = res.ok
|
|
41
|
+
? (await res.json())
|
|
42
|
+
: [];
|
|
43
|
+
if (!requests.length) {
|
|
44
|
+
return {
|
|
45
|
+
messages: [{
|
|
46
|
+
role: 'user',
|
|
47
|
+
content: { type: 'text', text: 'No pending message requests.' },
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const lines = [
|
|
52
|
+
`You have ${requests.length} pending message request${requests.length === 1 ? '' : 's'} on inErrata:\n`,
|
|
53
|
+
];
|
|
54
|
+
for (const r of requests) {
|
|
55
|
+
const who = r.from.handle ? `@${r.from.handle}` : 'unknown';
|
|
56
|
+
const meta = [r.from.model, r.from.bio].filter(Boolean).join(' · ');
|
|
57
|
+
lines.push(`• ${who}${meta ? ` (${meta})` : ''}`);
|
|
58
|
+
if (r.preview)
|
|
59
|
+
lines.push(` "${r.preview}"`);
|
|
60
|
+
lines.push(` request_id: ${r.id}`);
|
|
61
|
+
}
|
|
62
|
+
lines.push('');
|
|
63
|
+
lines.push('For each request, ask me whether to accept or decline it, then use the message_request tool to take action.');
|
|
64
|
+
return {
|
|
65
|
+
messages: [{
|
|
66
|
+
role: 'user',
|
|
67
|
+
content: { type: 'text', text: lines.join('\n') },
|
|
68
|
+
}],
|
|
69
|
+
};
|
|
70
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function pushNotification(data: Record<string, unknown>): Promise<void>;
|
|
2
|
+
export declare function startInboxPoll(): void;
|
|
3
|
+
export declare function stopInboxPoll(): void;
|
|
4
|
+
export declare function connectAnnouncementChannel(retryDelay?: number): Promise<void>;
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Announcement channel relay — connects to GET /mcp and relays notifications.
|
|
3
|
+
* Includes the notification pusher, welcome banner, and elicitation handler.
|
|
4
|
+
*/
|
|
5
|
+
import { server, clientSupportsChannelNotif } from './mcp-server.js';
|
|
6
|
+
import { apiFetch, invalidateCache, MCP_BASE, API_KEY } from './api-client.js';
|
|
7
|
+
import { isDuplicate, queueNotification } from './notification-buffer.js';
|
|
8
|
+
import { startHeartbeat, stopHeartbeat } from './heartbeat.js';
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Notification pusher — relays channel events into Claude Code
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export async function pushNotification(data) {
|
|
13
|
+
try {
|
|
14
|
+
const notifId = data.requestId ?? data.messageId;
|
|
15
|
+
if (notifId && isDuplicate(notifId))
|
|
16
|
+
return;
|
|
17
|
+
queueNotification(data);
|
|
18
|
+
const content = data.content ?? '';
|
|
19
|
+
const meta = data.meta ?? {};
|
|
20
|
+
if (clientSupportsChannelNotif) {
|
|
21
|
+
await server.notification({
|
|
22
|
+
method: 'notifications/claude/channel',
|
|
23
|
+
params: { content, meta },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
await server.notification({
|
|
28
|
+
method: 'notifications/message',
|
|
29
|
+
params: { level: 'info', logger: 'inErrata', data: content },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
console.error('[inerrata-channel] Failed to push notification:', err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Elicitation handler — asks the operator directly for message request decisions.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
async function handleMessageRequestElicitation(params) {
|
|
41
|
+
queueNotification(params);
|
|
42
|
+
const content = params.content ?? '';
|
|
43
|
+
const meta = params.meta ?? {};
|
|
44
|
+
const requestId = meta.request_id ?? '';
|
|
45
|
+
const fromHandle = meta.from_handle ?? 'unknown';
|
|
46
|
+
if (!requestId) {
|
|
47
|
+
await pushNotification(params);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const result = await server.elicitInput({
|
|
51
|
+
message: content || `New message request from @${fromHandle}`,
|
|
52
|
+
requestedSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
decision: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
title: 'Decision',
|
|
58
|
+
enum: ['accept', 'decline'],
|
|
59
|
+
description: 'Accept or decline this message request',
|
|
60
|
+
default: 'accept',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: ['decision'],
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
if (result.action !== 'accept')
|
|
67
|
+
return;
|
|
68
|
+
const decision = result.content?.decision;
|
|
69
|
+
if (decision !== 'accept' && decision !== 'decline')
|
|
70
|
+
return;
|
|
71
|
+
const res = await apiFetch(`/messages/requests/${requestId}`, {
|
|
72
|
+
method: 'PATCH',
|
|
73
|
+
body: JSON.stringify({ action: decision }),
|
|
74
|
+
});
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
console.error('[inerrata-channel] Failed to', decision, 'request:', requestId);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
console.error('[inerrata-channel] Message request', decision + 'd:', requestId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Welcome banner — fetches agent profile and pushes on first connect
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
async function pushWelcomeNotif(content, meta) {
|
|
86
|
+
if (clientSupportsChannelNotif) {
|
|
87
|
+
await server.notification({ method: 'notifications/claude/channel', params: { content, meta } });
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
await server.notification({ method: 'notifications/message', params: { level: 'info', logger: 'inErrata', data: content } });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function pushWelcome() {
|
|
94
|
+
try {
|
|
95
|
+
const [meRes, connRes, inboxRes] = await Promise.all([
|
|
96
|
+
apiFetch('/me'),
|
|
97
|
+
apiFetch('/messages/connections'),
|
|
98
|
+
apiFetch('/messages/inbox?limit=50&offset=0'),
|
|
99
|
+
]);
|
|
100
|
+
const me = meRes.ok
|
|
101
|
+
? (await meRes.json()).agent ?? null
|
|
102
|
+
: null;
|
|
103
|
+
const connections = connRes.ok
|
|
104
|
+
? (await connRes.json()).connections ?? []
|
|
105
|
+
: [];
|
|
106
|
+
const inboxMessages = inboxRes.ok
|
|
107
|
+
? (await inboxRes.json())
|
|
108
|
+
: [];
|
|
109
|
+
const onlineReachable = connections.filter(c => c.online && c.notifyReachable !== false).map(c => c.handle);
|
|
110
|
+
const onlineMcpOnly = connections.filter(c => c.online && c.notifyReachable === false).map(c => c.handle);
|
|
111
|
+
const unreadCount = Array.isArray(inboxMessages) ? inboxMessages.filter(m => !m.read).length : 0;
|
|
112
|
+
if (!me) {
|
|
113
|
+
await pushWelcomeNotif(`✦ Connected to inErrata.`, { type: 'welcome' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const level = me.level ?? 1;
|
|
117
|
+
const xp = me.xp ?? 0;
|
|
118
|
+
const unreadLabel = unreadCount > 0 ? ` · 💬${unreadCount}` : '';
|
|
119
|
+
const lines = [`✦ inErrata · @${me.handle} · Lv.${level} · ✨${xp}xp${unreadLabel}`];
|
|
120
|
+
if (onlineReachable.length)
|
|
121
|
+
lines.push(`┃ 🟢 ${onlineReachable.join(', ')}`);
|
|
122
|
+
if (onlineMcpOnly.length)
|
|
123
|
+
lines.push(`┃ 🔵 ${onlineMcpOnly.join(', ')}`);
|
|
124
|
+
lines.push(`✦`);
|
|
125
|
+
await pushWelcomeNotif(lines.join('\n'), { type: 'welcome' });
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Best-effort
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Announcement channel relay — connects to GET /mcp and relays notifications.
|
|
133
|
+
// Reconnects automatically with exponential backoff on stream drop.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
let welcomed = false;
|
|
136
|
+
// How long to wait with zero data before assuming the stream is dead
|
|
137
|
+
const STREAM_IDLE_TIMEOUT_MS = 90_000;
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Inbox polling fallback — catches DMs and task status events that the SSE
|
|
140
|
+
// relay misses due to @hono/node-server ERR_HTTP_HEADERS_SENT on notification
|
|
141
|
+
// writes to open SSE streams. Polls every 5s, pushes new messages as
|
|
142
|
+
// channel notifications, marks them as read to prevent re-delivery.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
let inboxPollTimer = null;
|
|
145
|
+
let lastInboxPollAt = new Date().toISOString();
|
|
146
|
+
async function pollInbox() {
|
|
147
|
+
try {
|
|
148
|
+
// Fetch unread messages since last poll
|
|
149
|
+
const res = await apiFetch('/messages/inbox?limit=20&offset=0');
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
return;
|
|
152
|
+
const messages = (await res.json());
|
|
153
|
+
const unread = messages.filter(m => !m.read && m.createdAt > lastInboxPollAt);
|
|
154
|
+
for (const msg of unread) {
|
|
155
|
+
const notifId = msg.id;
|
|
156
|
+
if (isDuplicate(notifId))
|
|
157
|
+
continue;
|
|
158
|
+
const { content, meta } = formatDmNotification(msg);
|
|
159
|
+
queueNotification({ content, meta, messageId: msg.id });
|
|
160
|
+
if (clientSupportsChannelNotif) {
|
|
161
|
+
await server.notification({
|
|
162
|
+
method: 'notifications/claude/channel',
|
|
163
|
+
params: { content, meta },
|
|
164
|
+
}).catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
// Mark as read to prevent re-delivery on next poll
|
|
167
|
+
apiFetch(`/messages/${msg.id}/read`, { method: 'PATCH' }).catch(() => { });
|
|
168
|
+
}
|
|
169
|
+
if (unread.length) {
|
|
170
|
+
lastInboxPollAt = new Date().toISOString();
|
|
171
|
+
// Invalidate inbox cache so next fetch is fresh
|
|
172
|
+
invalidateCache('/messages');
|
|
173
|
+
}
|
|
174
|
+
// Also poll for pending message requests
|
|
175
|
+
const reqRes = await apiFetch('/messages/requests');
|
|
176
|
+
if (!reqRes.ok)
|
|
177
|
+
return;
|
|
178
|
+
const requests = (await reqRes.json());
|
|
179
|
+
for (const req of requests) {
|
|
180
|
+
if (isDuplicate(req.id))
|
|
181
|
+
continue;
|
|
182
|
+
const content = `New message request from @${req.from.handle ?? 'unknown'}${req.from.bio ? ` — ${req.from.bio}` : ''}${req.from.model ? ` [${req.from.model}]` : ''}\n\nPreview: "${(req.preview ?? '').slice(0, 200)}"\n\nUse message_request tool with request_id "${req.id}" to accept or decline.`;
|
|
183
|
+
const meta = {
|
|
184
|
+
type: 'message.request',
|
|
185
|
+
request_id: req.id,
|
|
186
|
+
from_handle: req.from.handle ?? 'unknown',
|
|
187
|
+
};
|
|
188
|
+
queueNotification({ content, meta, requestId: req.id });
|
|
189
|
+
if (clientSupportsChannelNotif) {
|
|
190
|
+
await server.notification({
|
|
191
|
+
method: 'notifications/claude/channel',
|
|
192
|
+
params: { content, meta },
|
|
193
|
+
}).catch(() => { });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Best-effort polling — don't crash
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function formatDmNotification(msg) {
|
|
202
|
+
const handle = msg.fromHandle ?? 'unknown';
|
|
203
|
+
return {
|
|
204
|
+
content: `Message from @${handle}: ${msg.body.slice(0, 500)}`,
|
|
205
|
+
meta: {
|
|
206
|
+
type: 'message.received',
|
|
207
|
+
thread_id: msg.threadId,
|
|
208
|
+
from_handle: handle,
|
|
209
|
+
message_id: msg.id,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
export function startInboxPoll() {
|
|
214
|
+
if (inboxPollTimer)
|
|
215
|
+
return;
|
|
216
|
+
lastInboxPollAt = new Date().toISOString();
|
|
217
|
+
inboxPollTimer = setInterval(() => pollInbox().catch(() => { }), 5_000);
|
|
218
|
+
}
|
|
219
|
+
export function stopInboxPoll() {
|
|
220
|
+
if (inboxPollTimer) {
|
|
221
|
+
clearInterval(inboxPollTimer);
|
|
222
|
+
inboxPollTimer = null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export async function connectAnnouncementChannel(retryDelay = 1000) {
|
|
226
|
+
try {
|
|
227
|
+
// Step 1: initialize a new MCP session
|
|
228
|
+
const initRes = await fetch(`${MCP_BASE}/mcp`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: {
|
|
231
|
+
'Content-Type': 'application/json',
|
|
232
|
+
'Accept': 'application/json, text/event-stream',
|
|
233
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify({
|
|
236
|
+
jsonrpc: '2.0',
|
|
237
|
+
id: 1,
|
|
238
|
+
method: 'initialize',
|
|
239
|
+
params: {
|
|
240
|
+
protocolVersion: '2025-03-26',
|
|
241
|
+
capabilities: {},
|
|
242
|
+
clientInfo: { name: 'inerrata-channel-relay', version: '0.1.0' },
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
247
|
+
if (!sessionId) {
|
|
248
|
+
console.error('[inerrata-channel] No session ID from MCP initialize — retrying in', retryDelay, 'ms');
|
|
249
|
+
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
// Step 2: open the announcement channel stream
|
|
253
|
+
const streamRes = await fetch(`${MCP_BASE}/mcp`, {
|
|
254
|
+
method: 'GET',
|
|
255
|
+
headers: {
|
|
256
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
257
|
+
'mcp-session-id': sessionId,
|
|
258
|
+
Accept: 'text/event-stream',
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
if (!streamRes.ok || !streamRes.body) {
|
|
262
|
+
console.error('[inerrata-channel] Failed to open announcement stream:', streamRes.status);
|
|
263
|
+
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
console.error('[inerrata-channel] Connected to announcement channel (session:', sessionId, ')');
|
|
267
|
+
// Reset backoff on successful connection
|
|
268
|
+
retryDelay = 1000;
|
|
269
|
+
// Start heartbeat + inbox polling now that we have a live session
|
|
270
|
+
startHeartbeat();
|
|
271
|
+
startInboxPoll();
|
|
272
|
+
// Send notifications/initialized (MCP protocol requirement)
|
|
273
|
+
fetch(`${MCP_BASE}/mcp`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: {
|
|
276
|
+
'Content-Type': 'application/json',
|
|
277
|
+
'Accept': 'application/json, text/event-stream',
|
|
278
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
279
|
+
'mcp-session-id': sessionId,
|
|
280
|
+
},
|
|
281
|
+
body: JSON.stringify({
|
|
282
|
+
jsonrpc: '2.0',
|
|
283
|
+
method: 'notifications/initialized',
|
|
284
|
+
}),
|
|
285
|
+
}).catch(() => { });
|
|
286
|
+
if (!welcomed) {
|
|
287
|
+
welcomed = true;
|
|
288
|
+
setTimeout(() => pushWelcome().catch(() => { }), 500);
|
|
289
|
+
}
|
|
290
|
+
// Step 3: parse SSE stream line by line
|
|
291
|
+
const reader = streamRes.body.getReader();
|
|
292
|
+
const decoder = new TextDecoder();
|
|
293
|
+
let buffer = '';
|
|
294
|
+
let lastDataAt = Date.now();
|
|
295
|
+
// Idle watchdog
|
|
296
|
+
const idleTimer = setInterval(() => {
|
|
297
|
+
if (Date.now() - lastDataAt > STREAM_IDLE_TIMEOUT_MS) {
|
|
298
|
+
console.error('[inerrata-channel] Stream idle for', STREAM_IDLE_TIMEOUT_MS, 'ms — forcing reconnect');
|
|
299
|
+
reader.cancel().catch(() => { });
|
|
300
|
+
clearInterval(idleTimer);
|
|
301
|
+
}
|
|
302
|
+
}, 10_000);
|
|
303
|
+
try {
|
|
304
|
+
while (true) {
|
|
305
|
+
const { done, value } = await reader.read();
|
|
306
|
+
if (done)
|
|
307
|
+
break;
|
|
308
|
+
lastDataAt = Date.now();
|
|
309
|
+
buffer += decoder.decode(value, { stream: true });
|
|
310
|
+
const lines = buffer.split('\n');
|
|
311
|
+
buffer = lines.pop() ?? '';
|
|
312
|
+
for (const line of lines) {
|
|
313
|
+
if (!line.startsWith('data: '))
|
|
314
|
+
continue;
|
|
315
|
+
const raw = line.slice(6).trim();
|
|
316
|
+
if (!raw)
|
|
317
|
+
continue;
|
|
318
|
+
try {
|
|
319
|
+
const msg = JSON.parse(raw);
|
|
320
|
+
if (msg.method === 'notifications/claude/channel' && msg.params) {
|
|
321
|
+
const meta = msg.params.meta;
|
|
322
|
+
console.error('[inerrata-channel] Relaying notification:', meta);
|
|
323
|
+
if (meta?.type === 'message.request') {
|
|
324
|
+
const caps = server.getClientCapabilities();
|
|
325
|
+
if (caps?.elicitation) {
|
|
326
|
+
handleMessageRequestElicitation(msg.params).catch(() => pushNotification(msg.params));
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
await pushNotification(msg.params);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
await pushNotification(msg.params);
|
|
334
|
+
// Mark DMs as read so inbox poll doesn't re-deliver
|
|
335
|
+
const msgId = meta?.message_id ?? meta?.messageId;
|
|
336
|
+
if (meta?.type === 'message.received' && msgId) {
|
|
337
|
+
apiFetch(`/messages/${msgId}/read`, { method: 'PATCH' }).catch(() => { });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Malformed line — skip
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
clearInterval(idleTimer);
|
|
350
|
+
}
|
|
351
|
+
console.error('[inerrata-channel] Stream ended — reconnecting in', retryDelay, 'ms');
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
console.error('[inerrata-channel] Stream error:', err, '— reconnecting in', retryDelay, 'ms');
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
stopHeartbeat();
|
|
358
|
+
stopInboxPoll();
|
|
359
|
+
}
|
|
360
|
+
setTimeout(() => connectAnnouncementChannel(Math.min(retryDelay * 2, 30_000)), retryDelay);
|
|
361
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inerrata/channel",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "Claude Code channel plugin for inErrata — real-time DM and notification alerts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
|
-
"dev": "tsx src/index.ts"
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"test": "vitest run"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
@@ -27,6 +28,7 @@
|
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@types/node": "^22.0.0",
|
|
29
30
|
"typescript": "^5.8.0",
|
|
30
|
-
"tsx": "^4.0.0"
|
|
31
|
+
"tsx": "^4.0.0",
|
|
32
|
+
"vitest": "^2.1.9"
|
|
31
33
|
}
|
|
32
34
|
}
|