rogerthat 1.21.2
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/LICENSE +21 -0
- package/README.md +220 -0
- package/assets/logo.svg +30 -0
- package/assets/og-image.png +0 -0
- package/dist/account-ui.js +895 -0
- package/dist/accounts.js +253 -0
- package/dist/admin.js +303 -0
- package/dist/agentcard.js +76 -0
- package/dist/app.js +1140 -0
- package/dist/channel.js +526 -0
- package/dist/cli.js +158 -0
- package/dist/connect.js +224 -0
- package/dist/discovery.js +569 -0
- package/dist/email.js +67 -0
- package/dist/ids.js +24 -0
- package/dist/landing.js +558 -0
- package/dist/listen-here.js +491 -0
- package/dist/mcp.js +787 -0
- package/dist/policy.js +162 -0
- package/dist/presets.js +113 -0
- package/dist/receive-recipe.js +133 -0
- package/dist/remote-control.js +123 -0
- package/dist/remote-ui.js +850 -0
- package/dist/server.js +13 -0
- package/dist/stats.js +67 -0
- package/dist/store.js +228 -0
- package/dist/transcripts.js +68 -0
- package/dist/webhooks.js +154 -0
- package/package.json +77 -0
package/dist/channel.js
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
export const PRIORITY_RANK = {
|
|
2
|
+
min: 0,
|
|
3
|
+
low: 1,
|
|
4
|
+
default: 2,
|
|
5
|
+
high: 3,
|
|
6
|
+
urgent: 4,
|
|
7
|
+
};
|
|
8
|
+
export function isPriority(v) {
|
|
9
|
+
return v === "min" || v === "low" || v === "default" || v === "high" || v === "urgent";
|
|
10
|
+
}
|
|
11
|
+
export const ATTACHMENT_MIME_ALLOWLIST = new Set([
|
|
12
|
+
"image/jpeg",
|
|
13
|
+
"image/png",
|
|
14
|
+
"image/webp",
|
|
15
|
+
"image/gif",
|
|
16
|
+
"application/pdf",
|
|
17
|
+
]);
|
|
18
|
+
/** Per-message cap on TOTAL base64 size (sum across attachments). 512KB of
|
|
19
|
+
* base64 = ~384KB of raw bytes — enough for a screenshot or a small PDF.
|
|
20
|
+
* Worst case with ring buffer of 100 messages = 50MB RAM. */
|
|
21
|
+
export const MAX_ATTACHMENTS_BYTES_PER_MESSAGE = 512 * 1024;
|
|
22
|
+
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
|
|
23
|
+
export const MAX_SUGGESTED_REPLIES = 4;
|
|
24
|
+
export const MAX_SUGGESTED_REPLY_LENGTH = 64;
|
|
25
|
+
/** Validate + normalize attachments. Returns the cleaned array (with filenames
|
|
26
|
+
* trimmed, base64 stripped of whitespace) or throws ChannelError. Returns
|
|
27
|
+
* undefined if input is undefined/empty (caller meant "no attachments"). */
|
|
28
|
+
export function validateAttachments(v) {
|
|
29
|
+
if (v === undefined || v === null)
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!Array.isArray(v)) {
|
|
32
|
+
throw new ChannelError("attachments must be an array", "invalid", 400);
|
|
33
|
+
}
|
|
34
|
+
if (v.length === 0)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (v.length > MAX_ATTACHMENTS_PER_MESSAGE) {
|
|
37
|
+
throw new ChannelError(`attachments: max ${MAX_ATTACHMENTS_PER_MESSAGE} per message (got ${v.length})`, "invalid", 400);
|
|
38
|
+
}
|
|
39
|
+
const out = [];
|
|
40
|
+
let totalBytes = 0;
|
|
41
|
+
for (let i = 0; i < v.length; i++) {
|
|
42
|
+
const item = v[i];
|
|
43
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
44
|
+
throw new ChannelError(`attachments[${i}]: must be an object`, "invalid", 400);
|
|
45
|
+
}
|
|
46
|
+
const rec = item;
|
|
47
|
+
const mime = rec.mime;
|
|
48
|
+
if (typeof mime !== "string" || !ATTACHMENT_MIME_ALLOWLIST.has(mime)) {
|
|
49
|
+
throw new ChannelError(`attachments[${i}].mime: must be one of ${Array.from(ATTACHMENT_MIME_ALLOWLIST).join(", ")}`, "invalid", 400);
|
|
50
|
+
}
|
|
51
|
+
const dataRaw = rec.data_base64;
|
|
52
|
+
if (typeof dataRaw !== "string" || dataRaw.length === 0) {
|
|
53
|
+
throw new ChannelError(`attachments[${i}].data_base64: required string`, "invalid", 400);
|
|
54
|
+
}
|
|
55
|
+
// Strip whitespace from base64 (clients sometimes line-wrap).
|
|
56
|
+
const data = dataRaw.replace(/\s+/g, "");
|
|
57
|
+
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
|
|
58
|
+
throw new ChannelError(`attachments[${i}].data_base64: not valid base64`, "invalid", 400);
|
|
59
|
+
}
|
|
60
|
+
totalBytes += data.length;
|
|
61
|
+
if (totalBytes > MAX_ATTACHMENTS_BYTES_PER_MESSAGE) {
|
|
62
|
+
throw new ChannelError(`attachments total base64 size exceeds ${MAX_ATTACHMENTS_BYTES_PER_MESSAGE} bytes; host externally and paste a URL instead`, "invalid", 413);
|
|
63
|
+
}
|
|
64
|
+
// Verify decodability (catches truncation that the regex misses).
|
|
65
|
+
try {
|
|
66
|
+
Buffer.from(data, "base64");
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new ChannelError(`attachments[${i}].data_base64: decode failed`, "invalid", 400);
|
|
70
|
+
}
|
|
71
|
+
const attachment = { mime, data_base64: data };
|
|
72
|
+
if (typeof rec.filename === "string") {
|
|
73
|
+
const fn = rec.filename.trim().slice(0, 128);
|
|
74
|
+
if (fn)
|
|
75
|
+
attachment.filename = fn;
|
|
76
|
+
}
|
|
77
|
+
out.push(attachment);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/** Validate + normalize a suggested_replies array. Returns the cleaned array
|
|
82
|
+
* or throws a ChannelError describing what's wrong. Returns undefined if
|
|
83
|
+
* the input is undefined (the caller meant "no suggestions"). */
|
|
84
|
+
export function validateSuggestedReplies(v) {
|
|
85
|
+
if (v === undefined || v === null)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (!Array.isArray(v)) {
|
|
88
|
+
throw new ChannelError("suggested_replies must be an array of strings", "invalid", 400);
|
|
89
|
+
}
|
|
90
|
+
if (v.length === 0)
|
|
91
|
+
return undefined; // empty array = same as omitted
|
|
92
|
+
if (v.length > MAX_SUGGESTED_REPLIES) {
|
|
93
|
+
throw new ChannelError(`suggested_replies: max ${MAX_SUGGESTED_REPLIES} entries (got ${v.length})`, "invalid", 400);
|
|
94
|
+
}
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const item of v) {
|
|
97
|
+
if (typeof item !== "string") {
|
|
98
|
+
throw new ChannelError("suggested_replies entries must all be strings", "invalid", 400);
|
|
99
|
+
}
|
|
100
|
+
const trimmed = item.trim();
|
|
101
|
+
if (!trimmed)
|
|
102
|
+
continue; // skip empty strings silently
|
|
103
|
+
if (trimmed.length > MAX_SUGGESTED_REPLY_LENGTH) {
|
|
104
|
+
throw new ChannelError(`suggested_replies entry too long (max ${MAX_SUGGESTED_REPLY_LENGTH} chars)`, "invalid", 400);
|
|
105
|
+
}
|
|
106
|
+
out.push(trimmed);
|
|
107
|
+
}
|
|
108
|
+
return out.length > 0 ? out : undefined;
|
|
109
|
+
}
|
|
110
|
+
const HISTORY_CAP = 100;
|
|
111
|
+
// Default idle TTL; channels can override via session_ttl_seconds at creation (max 24h).
|
|
112
|
+
const DEFAULT_ROSTER_IDLE_MS = 30 * 60 * 1000;
|
|
113
|
+
const EVICTION_TOMBSTONE_MS = 60 * 60 * 1000; // remember evicted sessions for 1h so we can return 410 instead of 400
|
|
114
|
+
export class ChannelError extends Error {
|
|
115
|
+
code;
|
|
116
|
+
status;
|
|
117
|
+
constructor(message, code, status) {
|
|
118
|
+
super(message);
|
|
119
|
+
this.code = code;
|
|
120
|
+
this.status = status;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export class Channel {
|
|
124
|
+
id;
|
|
125
|
+
callsignBySession = new Map();
|
|
126
|
+
sessionByCallsign = new Map();
|
|
127
|
+
lastSeen = new Map();
|
|
128
|
+
messages = [];
|
|
129
|
+
// Per-callsign delivery cursor: last msg id delivered to that callsign. Persists across
|
|
130
|
+
// session expiry so offline messages get delivered when the callsign rejoins.
|
|
131
|
+
cursorByCallsign = new Map();
|
|
132
|
+
// Every callsign that has joined the channel at least once. Used to allow DMing offline agents.
|
|
133
|
+
historicCallsigns = new Set();
|
|
134
|
+
listenersBySession = new Map();
|
|
135
|
+
// Persistent stream listeners (SSE). Unlike long-poll listeners, these are NOT removed
|
|
136
|
+
// after a single delivery — they keep receiving until the consumer explicitly detaches
|
|
137
|
+
// or the session is evicted. Sessions with an active streamer also count as "alive"
|
|
138
|
+
// for GC purposes, so a parked agent with an open SSE connection won't be reaped.
|
|
139
|
+
streamersBySession = new Map();
|
|
140
|
+
evictedSessions = new Map(); // sessionId -> evictedAt (tombstones)
|
|
141
|
+
// Monotonic ID generator using current epoch time. Guarantees strict-increase
|
|
142
|
+
// across restarts as long as the system clock doesn't go backwards.
|
|
143
|
+
nextMsgId = Date.now();
|
|
144
|
+
joinOrder = [];
|
|
145
|
+
firstJoinedAt = null;
|
|
146
|
+
lastActivityAt = Date.now();
|
|
147
|
+
/** Idle TTL in ms before sessions are GC'd. Settable per channel; defaults to 30 min. */
|
|
148
|
+
sessionTtlMs = DEFAULT_ROSTER_IDLE_MS;
|
|
149
|
+
constructor(id) {
|
|
150
|
+
this.id = id;
|
|
151
|
+
}
|
|
152
|
+
touch(sessionId) {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
this.lastSeen.set(sessionId, now);
|
|
155
|
+
this.lastActivityAt = now;
|
|
156
|
+
}
|
|
157
|
+
gcRoster() {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
for (const [session, last] of this.lastSeen) {
|
|
160
|
+
if (now - last > this.sessionTtlMs &&
|
|
161
|
+
!this.listenersBySession.has(session) &&
|
|
162
|
+
!this.streamersBySession.has(session)) {
|
|
163
|
+
this.evictSession(session);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const [session, evictedAt] of this.evictedSessions) {
|
|
167
|
+
if (now - evictedAt > EVICTION_TOMBSTONE_MS)
|
|
168
|
+
this.evictedSessions.delete(session);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
ensureJoined(sessionId) {
|
|
172
|
+
if (this.callsignBySession.has(sessionId))
|
|
173
|
+
return;
|
|
174
|
+
if (this.evictedSessions.has(sessionId)) {
|
|
175
|
+
throw new ChannelError("session expired; call /join with the same callsign+token to refresh (session_id is reusable)", "session_expired", 410);
|
|
176
|
+
}
|
|
177
|
+
throw new ChannelError("not joined to channel; call /join with {callsign, token} first", "not_joined", 400);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Idempotent join.
|
|
181
|
+
* - Same `(sessionId, callsign)` is a no-op (refreshes lastSeen).
|
|
182
|
+
* - `opts.selfGenerated=true` means the caller has no prior identity (REST minted a UUID for
|
|
183
|
+
* them); if the callsign is already taken, we return the existing session_id so the caller
|
|
184
|
+
* can adopt it. Enables the "defensively re-join every turn" pattern.
|
|
185
|
+
* - `opts.selfGenerated=false` (default) means the caller's sessionId IS their identity (MCP
|
|
186
|
+
* Mcp-Session-Id, or REST with X-Session-Id). If the callsign is taken by a *different*
|
|
187
|
+
* session, throws `callsign_taken` (409) rather than silently mapping them to someone
|
|
188
|
+
* else's session. Previous behavior silently broke send/listen for the conflicting caller.
|
|
189
|
+
*/
|
|
190
|
+
join(sessionId, callsign, opts = {}) {
|
|
191
|
+
const normalized = callsign.trim().toLowerCase();
|
|
192
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
|
|
193
|
+
throw new ChannelError("callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit", "invalid", 400);
|
|
194
|
+
}
|
|
195
|
+
if (normalized === "all") {
|
|
196
|
+
throw new ChannelError('callsign "all" is reserved for broadcast', "invalid", 400);
|
|
197
|
+
}
|
|
198
|
+
const existingSession = this.sessionByCallsign.get(normalized);
|
|
199
|
+
let idempotent = false;
|
|
200
|
+
const effectiveId = sessionId;
|
|
201
|
+
if (existingSession) {
|
|
202
|
+
if (existingSession === sessionId) {
|
|
203
|
+
idempotent = true;
|
|
204
|
+
}
|
|
205
|
+
else if (opts.selfGenerated) {
|
|
206
|
+
// Caller had no identity (REST minted a UUID for them) and the callsign is taken —
|
|
207
|
+
// hand back the existing session_id so they can adopt it.
|
|
208
|
+
this.evictedSessions.delete(sessionId);
|
|
209
|
+
this.touch(existingSession);
|
|
210
|
+
return {
|
|
211
|
+
sessionId: existingSession,
|
|
212
|
+
roster: this.roster(),
|
|
213
|
+
history: this.history(20),
|
|
214
|
+
idempotent: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
throw new ChannelError(`callsign "${normalized}" is already in use on this channel; pick a different one or have the current holder leave first`, "callsign_taken", 409);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const prevCallsign = this.callsignBySession.get(sessionId);
|
|
222
|
+
if (prevCallsign && prevCallsign !== normalized) {
|
|
223
|
+
this.sessionByCallsign.delete(prevCallsign);
|
|
224
|
+
this.joinOrder = this.joinOrder.filter((a) => a.callsign !== prevCallsign);
|
|
225
|
+
}
|
|
226
|
+
this.callsignBySession.set(sessionId, normalized);
|
|
227
|
+
this.sessionByCallsign.set(normalized, sessionId);
|
|
228
|
+
this.evictedSessions.delete(sessionId);
|
|
229
|
+
this.touch(sessionId);
|
|
230
|
+
if (this.firstJoinedAt === null)
|
|
231
|
+
this.firstJoinedAt = Date.now();
|
|
232
|
+
// First time we see this callsign on this channel: cursor starts at 0 so all queued
|
|
233
|
+
// offline messages to=callsign get delivered on the next listen. Subsequent joins
|
|
234
|
+
// preserve the existing cursor so we don't re-deliver.
|
|
235
|
+
if (!this.cursorByCallsign.has(normalized)) {
|
|
236
|
+
this.cursorByCallsign.set(normalized, 0);
|
|
237
|
+
}
|
|
238
|
+
this.historicCallsigns.add(normalized);
|
|
239
|
+
if (!this.joinOrder.some((a) => a.callsign === normalized)) {
|
|
240
|
+
this.joinOrder.push({ callsign: normalized, joinedAt: Date.now() });
|
|
241
|
+
}
|
|
242
|
+
return { sessionId: effectiveId, roster: this.roster(), history: this.history(20), idempotent };
|
|
243
|
+
}
|
|
244
|
+
isCallsignOnline(callsign) {
|
|
245
|
+
if (callsign === "all")
|
|
246
|
+
return true;
|
|
247
|
+
return this.sessionByCallsign.has(callsign.trim().toLowerCase());
|
|
248
|
+
}
|
|
249
|
+
knowsCallsign(callsign) {
|
|
250
|
+
const cs = callsign.trim().toLowerCase();
|
|
251
|
+
return cs === "all" || this.historicCallsigns.has(cs);
|
|
252
|
+
}
|
|
253
|
+
keepalive(sessionId) {
|
|
254
|
+
this.ensureJoined(sessionId);
|
|
255
|
+
this.touch(sessionId);
|
|
256
|
+
}
|
|
257
|
+
evictSession(sessionId) {
|
|
258
|
+
const listener = this.listenersBySession.get(sessionId);
|
|
259
|
+
if (listener) {
|
|
260
|
+
clearTimeout(listener.timer);
|
|
261
|
+
listener.resolve([]);
|
|
262
|
+
this.listenersBySession.delete(sessionId);
|
|
263
|
+
}
|
|
264
|
+
// Drop any persistent stream listener too. The SSE handler detects the next
|
|
265
|
+
// write failure (or its own abort signal) and closes the connection.
|
|
266
|
+
this.streamersBySession.delete(sessionId);
|
|
267
|
+
const cs = this.callsignBySession.get(sessionId);
|
|
268
|
+
if (cs) {
|
|
269
|
+
this.sessionByCallsign.delete(cs);
|
|
270
|
+
this.joinOrder = this.joinOrder.filter((a) => a.callsign !== cs);
|
|
271
|
+
}
|
|
272
|
+
if (this.callsignBySession.has(sessionId)) {
|
|
273
|
+
this.evictedSessions.set(sessionId, Date.now());
|
|
274
|
+
}
|
|
275
|
+
this.callsignBySession.delete(sessionId);
|
|
276
|
+
this.lastSeen.delete(sessionId);
|
|
277
|
+
// Note: do NOT delete cursorByCallsign[cs] — keeps the offline-delivery pointer alive
|
|
278
|
+
// so when this callsign rejoins, they get the messages queued for them while away.
|
|
279
|
+
}
|
|
280
|
+
leave(sessionId) {
|
|
281
|
+
this.evictSession(sessionId);
|
|
282
|
+
}
|
|
283
|
+
callsignOf(sessionId) {
|
|
284
|
+
return this.callsignBySession.get(sessionId);
|
|
285
|
+
}
|
|
286
|
+
resolveAddress(to) {
|
|
287
|
+
const trimmed = to.trim().toLowerCase();
|
|
288
|
+
if (!trimmed)
|
|
289
|
+
return "";
|
|
290
|
+
if (trimmed === "all")
|
|
291
|
+
return "all";
|
|
292
|
+
const idxMatch = /^#?(\d+)$/.exec(trimmed);
|
|
293
|
+
if (idxMatch) {
|
|
294
|
+
const idx = Number.parseInt(idxMatch[1], 10);
|
|
295
|
+
if (idx >= 1 && idx <= this.joinOrder.length) {
|
|
296
|
+
return this.joinOrder[idx - 1].callsign;
|
|
297
|
+
}
|
|
298
|
+
return trimmed;
|
|
299
|
+
}
|
|
300
|
+
return trimmed;
|
|
301
|
+
}
|
|
302
|
+
sessionExists(sessionId) {
|
|
303
|
+
return this.callsignBySession.has(sessionId);
|
|
304
|
+
}
|
|
305
|
+
send(sessionId, to, text, priority, suggestedReplies, attachments) {
|
|
306
|
+
this.ensureJoined(sessionId);
|
|
307
|
+
const from = this.callsignBySession.get(sessionId);
|
|
308
|
+
// Empty/missing `to` defaults to broadcast. Walkie-talkie physical default —
|
|
309
|
+
// press-to-talk goes to everyone on the channel. Agents that omit the field
|
|
310
|
+
// (a common first-call mistake) get sensible behavior instead of an error.
|
|
311
|
+
const dest = this.resolveAddress(to) || "all";
|
|
312
|
+
if (dest !== "all" && !this.sessionByCallsign.has(dest) && !this.historicCallsigns.has(dest)) {
|
|
313
|
+
throw new ChannelError(`no callsign "${to}" has ever been on this channel (roster: ${this.rosterWithIndex().map((a) => `#${a.idx} ${a.callsign}`).join(", ") || "empty"}). DM to historic callsigns is supported — but they must have joined at least once.`, "invalid", 400);
|
|
314
|
+
}
|
|
315
|
+
if (typeof text !== "string") {
|
|
316
|
+
throw new ChannelError("message text must be a string", "invalid", 400);
|
|
317
|
+
}
|
|
318
|
+
// Empty text is allowed when at least one attachment is present (sending
|
|
319
|
+
// just an image without a caption). Otherwise we need a non-empty body.
|
|
320
|
+
if (text.length === 0 && (!attachments || attachments.length === 0)) {
|
|
321
|
+
throw new ChannelError("message text required (or send at least one attachment)", "invalid", 400);
|
|
322
|
+
}
|
|
323
|
+
if (text.length > 8192) {
|
|
324
|
+
throw new ChannelError("message too long (max 8192 chars)", "invalid", 400);
|
|
325
|
+
}
|
|
326
|
+
this.touch(sessionId);
|
|
327
|
+
// Strictly-monotonic timestamp ID: at least one millisecond ahead of the prior id, and at
|
|
328
|
+
// least the current wall clock. Survives restarts as long as the clock advances.
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
this.nextMsgId = Math.max(now, this.nextMsgId + 1);
|
|
331
|
+
const msg = { id: this.nextMsgId, from, to: dest, text, at: now };
|
|
332
|
+
// Only attach `priority` when explicitly non-default — keeps the wire format
|
|
333
|
+
// backward-compatible for consumers that don't know about priorities.
|
|
334
|
+
if (priority && priority !== "default")
|
|
335
|
+
msg.priority = priority;
|
|
336
|
+
if (suggestedReplies && suggestedReplies.length > 0) {
|
|
337
|
+
msg.suggested_replies = suggestedReplies;
|
|
338
|
+
}
|
|
339
|
+
if (attachments && attachments.length > 0) {
|
|
340
|
+
msg.attachments = attachments;
|
|
341
|
+
}
|
|
342
|
+
this.messages.push(msg);
|
|
343
|
+
if (this.messages.length > HISTORY_CAP)
|
|
344
|
+
this.messages.shift();
|
|
345
|
+
this.notify(msg);
|
|
346
|
+
return msg;
|
|
347
|
+
}
|
|
348
|
+
notify(msg) {
|
|
349
|
+
for (const [session, listener] of [...this.listenersBySession]) {
|
|
350
|
+
const cs = this.callsignBySession.get(session);
|
|
351
|
+
if (!cs)
|
|
352
|
+
continue;
|
|
353
|
+
if (msg.from === cs)
|
|
354
|
+
continue;
|
|
355
|
+
if (msg.to !== "all" && msg.to !== cs)
|
|
356
|
+
continue;
|
|
357
|
+
this.listenersBySession.delete(session);
|
|
358
|
+
clearTimeout(listener.timer);
|
|
359
|
+
this.cursorByCallsign.set(cs, msg.id);
|
|
360
|
+
listener.resolve([msg]);
|
|
361
|
+
}
|
|
362
|
+
// Persistent stream listeners (SSE). Not removed after delivery — keep firing.
|
|
363
|
+
// Refresh the per-session lastSeen so streamers count as activity for GC.
|
|
364
|
+
for (const [session, onMessage] of this.streamersBySession) {
|
|
365
|
+
const cs = this.callsignBySession.get(session);
|
|
366
|
+
if (!cs)
|
|
367
|
+
continue;
|
|
368
|
+
if (msg.from === cs)
|
|
369
|
+
continue;
|
|
370
|
+
if (msg.to !== "all" && msg.to !== cs)
|
|
371
|
+
continue;
|
|
372
|
+
this.cursorByCallsign.set(cs, msg.id);
|
|
373
|
+
this.touch(session);
|
|
374
|
+
try {
|
|
375
|
+
onMessage(msg);
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
console.error(`[stream ${this.id}/${cs}] handler threw:`, err);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Register a persistent listener for incoming messages addressed to this session's
|
|
384
|
+
* callsign (DMs or broadcasts). Unlike `listen`, the listener is NOT removed after
|
|
385
|
+
* a single delivery — the caller keeps receiving until they call the returned
|
|
386
|
+
* cleanup function (or the session is evicted). Designed for SSE / WebSocket-style
|
|
387
|
+
* push consumers.
|
|
388
|
+
*
|
|
389
|
+
* Callers typically want to call `drainSince(sessionId, since)` immediately after
|
|
390
|
+
* registering, to flush any backlog the cursor was sitting on, then rely on this
|
|
391
|
+
* listener for everything after.
|
|
392
|
+
*/
|
|
393
|
+
addStreamListener(sessionId, onMessage) {
|
|
394
|
+
this.ensureJoined(sessionId);
|
|
395
|
+
this.touch(sessionId);
|
|
396
|
+
this.streamersBySession.set(sessionId, onMessage);
|
|
397
|
+
return () => {
|
|
398
|
+
if (this.streamersBySession.get(sessionId) === onMessage) {
|
|
399
|
+
this.streamersBySession.delete(sessionId);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Return any messages already in the buffer that this session's callsign hasn't
|
|
405
|
+
* seen yet, and advance the per-callsign cursor past them. Same selection logic
|
|
406
|
+
* as `listen()` but returns immediately (no long-poll). Use with `addStreamListener`
|
|
407
|
+
* to bootstrap an SSE/streaming subscription without losing the backlog.
|
|
408
|
+
*/
|
|
409
|
+
drainSince(sessionId, since) {
|
|
410
|
+
this.ensureJoined(sessionId);
|
|
411
|
+
this.touch(sessionId);
|
|
412
|
+
const cs = this.callsignBySession.get(sessionId);
|
|
413
|
+
const cursor = since !== undefined ? since : (this.cursorByCallsign.get(cs) ?? 0);
|
|
414
|
+
const pending = this.messages.filter((m) => m.id > cursor && m.from !== cs && (m.to === "all" || m.to === cs));
|
|
415
|
+
if (pending.length > 0) {
|
|
416
|
+
this.cursorByCallsign.set(cs, pending[pending.length - 1].id);
|
|
417
|
+
}
|
|
418
|
+
return pending;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Long-poll for incoming messages.
|
|
422
|
+
* - When `since` is undefined, returns messages newer than this session's per-session cursor
|
|
423
|
+
* (default behaviour, equivalent to a read pointer the server manages for you).
|
|
424
|
+
* - When `since` is provided, returns messages with `id > since` regardless of the per-session
|
|
425
|
+
* cursor. Useful after a session expiry/restart to catch up reliably from a known id.
|
|
426
|
+
*/
|
|
427
|
+
async listen(sessionId, timeoutMs, since) {
|
|
428
|
+
this.ensureJoined(sessionId);
|
|
429
|
+
this.touch(sessionId);
|
|
430
|
+
const cs = this.callsignBySession.get(sessionId);
|
|
431
|
+
// Per-callsign cursor → offline delivery: if alpha was offline, then someone sent to=alpha,
|
|
432
|
+
// alpha rejoins, listen returns those messages because the cursor stayed at the last-delivered id.
|
|
433
|
+
const cursor = since !== undefined ? since : (this.cursorByCallsign.get(cs) ?? 0);
|
|
434
|
+
const pending = this.messages.filter((m) => m.id > cursor && m.from !== cs && (m.to === "all" || m.to === cs));
|
|
435
|
+
if (pending.length > 0) {
|
|
436
|
+
this.cursorByCallsign.set(cs, pending[pending.length - 1].id);
|
|
437
|
+
return pending;
|
|
438
|
+
}
|
|
439
|
+
const existing = this.listenersBySession.get(sessionId);
|
|
440
|
+
if (existing) {
|
|
441
|
+
clearTimeout(existing.timer);
|
|
442
|
+
existing.resolve([]);
|
|
443
|
+
this.listenersBySession.delete(sessionId);
|
|
444
|
+
}
|
|
445
|
+
return new Promise((resolve) => {
|
|
446
|
+
const timer = setTimeout(() => {
|
|
447
|
+
this.listenersBySession.delete(sessionId);
|
|
448
|
+
resolve([]);
|
|
449
|
+
}, timeoutMs);
|
|
450
|
+
this.listenersBySession.set(sessionId, { resolve, timer });
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
roster() {
|
|
454
|
+
return [...this.sessionByCallsign.keys()].sort();
|
|
455
|
+
}
|
|
456
|
+
rosterWithIndex() {
|
|
457
|
+
return this.joinOrder
|
|
458
|
+
.filter((a) => this.sessionByCallsign.has(a.callsign))
|
|
459
|
+
.map((a, i) => ({ idx: i + 1, callsign: a.callsign, joined_at: a.joinedAt }));
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Roster including historic (offline) callsigns with an `online` flag.
|
|
463
|
+
* Useful for "show me everyone who's ever been on this channel" — and lets
|
|
464
|
+
* a sender know who's currently reachable vs queued.
|
|
465
|
+
*/
|
|
466
|
+
rosterAll() {
|
|
467
|
+
const onlineIdx = new Map();
|
|
468
|
+
const onlineList = this.rosterWithIndex();
|
|
469
|
+
for (const a of onlineList)
|
|
470
|
+
onlineIdx.set(a.callsign, a.idx);
|
|
471
|
+
const all = [...this.historicCallsigns];
|
|
472
|
+
all.sort((a, b) => a.localeCompare(b));
|
|
473
|
+
return all.map((cs) => ({
|
|
474
|
+
callsign: cs,
|
|
475
|
+
online: this.sessionByCallsign.has(cs),
|
|
476
|
+
idx: onlineIdx.get(cs) ?? null,
|
|
477
|
+
}));
|
|
478
|
+
}
|
|
479
|
+
history(n) {
|
|
480
|
+
const clamped = Math.max(1, Math.min(HISTORY_CAP, Math.floor(n)));
|
|
481
|
+
return this.messages.slice(-clamped);
|
|
482
|
+
}
|
|
483
|
+
size() {
|
|
484
|
+
return this.callsignBySession.size;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const channels = new Map();
|
|
488
|
+
let sessionTtlLookup = () => DEFAULT_ROSTER_IDLE_MS;
|
|
489
|
+
export function setSessionTtlLookup(fn) {
|
|
490
|
+
sessionTtlLookup = fn;
|
|
491
|
+
}
|
|
492
|
+
export function getOrCreateChannel(id) {
|
|
493
|
+
let ch = channels.get(id);
|
|
494
|
+
if (!ch) {
|
|
495
|
+
ch = new Channel(id);
|
|
496
|
+
ch.sessionTtlMs = sessionTtlLookup(id);
|
|
497
|
+
channels.set(id, ch);
|
|
498
|
+
}
|
|
499
|
+
return ch;
|
|
500
|
+
}
|
|
501
|
+
let gcTimer = null;
|
|
502
|
+
export function startPeriodicGc(intervalMs = 60_000) {
|
|
503
|
+
if (gcTimer)
|
|
504
|
+
return;
|
|
505
|
+
gcTimer = setInterval(() => {
|
|
506
|
+
for (const ch of channels.values())
|
|
507
|
+
ch.gcRoster();
|
|
508
|
+
}, intervalMs);
|
|
509
|
+
gcTimer.unref?.();
|
|
510
|
+
}
|
|
511
|
+
export function listActiveChannels(retentionFor, requireIdentityFor, trustModeFor) {
|
|
512
|
+
return [...channels.values()]
|
|
513
|
+
.filter((c) => c.size() > 0 || c.firstJoinedAt !== null)
|
|
514
|
+
.map((c) => ({
|
|
515
|
+
id: c.id,
|
|
516
|
+
retention: retentionFor(c.id),
|
|
517
|
+
require_identity: requireIdentityFor(c.id),
|
|
518
|
+
trust_mode: trustModeFor(c.id),
|
|
519
|
+
roster: c.roster(),
|
|
520
|
+
agent_count: c.size(),
|
|
521
|
+
message_count: c.history(100).length,
|
|
522
|
+
first_joined_at: c.firstJoinedAt,
|
|
523
|
+
last_activity_at: c.lastActivityAt,
|
|
524
|
+
}))
|
|
525
|
+
.sort((a, b) => b.last_activity_at - a.last_activity_at);
|
|
526
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// IMPORTANT: server-side imports (`@hono/node-server`, `./app.js`) live inside
|
|
3
|
+
// the `runServer()` function so they're only loaded when the user actually
|
|
4
|
+
// starts the local hub. Subcommands like `listen-here` and `receive-recipe`
|
|
5
|
+
// must work on Node 16+ — they only use `fetch` / `URL` / fs, no Hono. Putting
|
|
6
|
+
// the server imports at top-of-file caused `npx rogerthat listen-here` to crash
|
|
7
|
+
// on older Node versions with `Class extends value undefined is not a
|
|
8
|
+
// constructor` from `@hono/node-server`'s `class extends GlobalRequest`.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
import { runListenHere } from "./listen-here.js";
|
|
15
|
+
import { runReceiveRecipe } from "./receive-recipe.js";
|
|
16
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
let PKG_VERSION = "?";
|
|
18
|
+
try {
|
|
19
|
+
PKG_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
/* keep "?" if not found */
|
|
23
|
+
}
|
|
24
|
+
const HELP = `rogerthat ${PKG_VERSION} — walkie-talkie MCP hub for AI agents
|
|
25
|
+
|
|
26
|
+
usage:
|
|
27
|
+
rogerthat [options] # run the local hub (default)
|
|
28
|
+
rogerthat listen-here [options] # open an SSE receiver for a channel (see --help)
|
|
29
|
+
rogerthat receive-recipe [options] # print copy-paste recipe: listener + Monitor cmd
|
|
30
|
+
|
|
31
|
+
options:
|
|
32
|
+
--port <n> port to listen on (default: 7424)
|
|
33
|
+
--host <addr> interface to bind (default: 127.0.0.1)
|
|
34
|
+
--token <secret> require Bearer token on /mcp/* requests
|
|
35
|
+
(required when --host is not 127.0.0.1 or localhost)
|
|
36
|
+
--admin-token <s> enable /admin dashboard with this token
|
|
37
|
+
(metadata only — never exposes message content)
|
|
38
|
+
--data-dir <path> single directory holding all rogerthat data
|
|
39
|
+
(default: ~/.rogerthat — channels.json, accounts.json,
|
|
40
|
+
identities.json, stats.json, webhooks.json, transcripts/
|
|
41
|
+
all live here)
|
|
42
|
+
--data <path> legacy: just the channels.json path (overrides data-dir)
|
|
43
|
+
--origin <url> public origin advertised in connect snippets
|
|
44
|
+
(default: http://<host>:<port>)
|
|
45
|
+
--help, -h show this help
|
|
46
|
+
|
|
47
|
+
examples:
|
|
48
|
+
rogerthat # local only, no auth, data in ~/.rogerthat
|
|
49
|
+
rogerthat --port 9000 # different port
|
|
50
|
+
rogerthat --host 0.0.0.0 --token sekret # LAN with auth (bearer required)
|
|
51
|
+
rogerthat --data-dir /var/lib/rogerthat # custom data directory
|
|
52
|
+
rogerthat --origin https://my.example # if behind a reverse proxy
|
|
53
|
+
|
|
54
|
+
after starting, install once in your AI client:
|
|
55
|
+
claude mcp add --transport http rogerthat http://127.0.0.1:7424/mcp
|
|
56
|
+
|
|
57
|
+
then in any session: "create a rogerthat channel" — Claude calls the
|
|
58
|
+
create_channel tool and prints a snippet to share with the other agent.
|
|
59
|
+
|
|
60
|
+
docs: https://rogerthat.chat
|
|
61
|
+
`;
|
|
62
|
+
function isLocalHost(host) {
|
|
63
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
64
|
+
}
|
|
65
|
+
async function main() {
|
|
66
|
+
// Subcommand dispatch: anything before flags. Detect by argv[2] being a
|
|
67
|
+
// non-flag word.
|
|
68
|
+
const first = process.argv[2];
|
|
69
|
+
if (first === "listen-here") {
|
|
70
|
+
const code = await runListenHere(process.argv.slice(3));
|
|
71
|
+
process.exit(code);
|
|
72
|
+
}
|
|
73
|
+
if (first === "receive-recipe") {
|
|
74
|
+
const code = runReceiveRecipe(process.argv.slice(3));
|
|
75
|
+
process.exit(code);
|
|
76
|
+
}
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = parseArgs({
|
|
80
|
+
options: {
|
|
81
|
+
port: { type: "string" },
|
|
82
|
+
host: { type: "string" },
|
|
83
|
+
token: { type: "string" },
|
|
84
|
+
"admin-token": { type: "string" },
|
|
85
|
+
"data-dir": { type: "string" },
|
|
86
|
+
data: { type: "string" },
|
|
87
|
+
origin: { type: "string" },
|
|
88
|
+
help: { type: "boolean", short: "h" },
|
|
89
|
+
},
|
|
90
|
+
strict: true,
|
|
91
|
+
allowPositionals: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
console.error(`error: ${e.message}\n`);
|
|
96
|
+
console.error(HELP);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
if (parsed.values.help) {
|
|
100
|
+
console.log(HELP);
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
const port = Number(parsed.values.port ?? 7424);
|
|
104
|
+
const host = parsed.values.host ?? "127.0.0.1";
|
|
105
|
+
const token = parsed.values.token;
|
|
106
|
+
const adminToken = parsed.values["admin-token"];
|
|
107
|
+
const dataDir = parsed.values["data-dir"] ?? join(homedir(), ".rogerthat");
|
|
108
|
+
if (!existsSync(dataDir))
|
|
109
|
+
mkdirSync(dataDir, { recursive: true });
|
|
110
|
+
const dataPath = parsed.values.data ?? join(dataDir, "channels.json");
|
|
111
|
+
const origin = parsed.values.origin ?? `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
|
|
112
|
+
if (!isLocalHost(host) && !token) {
|
|
113
|
+
console.error(`error: --token is required when binding to ${host} (non-localhost). use --token to set a shared secret, or --host 127.0.0.1 to restrict to local.`);
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
// Centralize all server-side state under one directory. The data-dir is the umbrella;
|
|
117
|
+
// individual --xxx flags can still override specific files for power users.
|
|
118
|
+
process.env.ROGERRAT_DB = dataPath;
|
|
119
|
+
process.env.ROGERRAT_ACCOUNTS = process.env.ROGERRAT_ACCOUNTS ?? join(dataDir, "accounts.json");
|
|
120
|
+
process.env.ROGERRAT_IDENTITIES = process.env.ROGERRAT_IDENTITIES ?? join(dataDir, "identities.json");
|
|
121
|
+
process.env.ROGERRAT_STATS = process.env.ROGERRAT_STATS ?? join(dataDir, "stats.json");
|
|
122
|
+
process.env.ROGERRAT_TRANSCRIPTS = process.env.ROGERRAT_TRANSCRIPTS ?? join(dataDir, "transcripts");
|
|
123
|
+
process.env.ROGERRAT_WEBHOOKS = process.env.ROGERRAT_WEBHOOKS ?? join(dataDir, "webhooks.json");
|
|
124
|
+
// Dynamic import keeps server-side modules (Hono, etc.) off the cold path for
|
|
125
|
+
// `listen-here` and `receive-recipe`. Those need to work on Node 16+, where
|
|
126
|
+
// `@hono/node-server`'s `class extends GlobalRequest` blows up at module-load
|
|
127
|
+
// time even if we never instantiate it.
|
|
128
|
+
const { createApp } = await import("./app.js");
|
|
129
|
+
const { serve } = await import("@hono/node-server");
|
|
130
|
+
const app = createApp({
|
|
131
|
+
publicOrigin: origin,
|
|
132
|
+
authRequired: !!token,
|
|
133
|
+
staticToken: token,
|
|
134
|
+
adminToken,
|
|
135
|
+
});
|
|
136
|
+
console.log(`rogerthat ${PKG_VERSION} — local walkie-talkie hub`);
|
|
137
|
+
console.log(` listening on http://${host}:${port}`);
|
|
138
|
+
console.log(` public origin ${origin}`);
|
|
139
|
+
console.log(` data dir ${dataDir}`);
|
|
140
|
+
console.log(` auth ${token ? "required (bearer token on /mcp/*)" : "disabled (local-only)"}`);
|
|
141
|
+
console.log(` admin UI ${adminToken ? `enabled at ${origin}/admin` : "disabled (pass --admin-token to enable)"}`);
|
|
142
|
+
console.log(` email recovery ${process.env.RESEND_API_KEY ? "enabled (Resend)" : "disabled (set RESEND_API_KEY to enable)"}`);
|
|
143
|
+
console.log("");
|
|
144
|
+
console.log(`install once in your AI client:`);
|
|
145
|
+
console.log(` claude mcp add --transport http rogerthat ${origin}/mcp${token ? ` --header "Authorization: Bearer ${token}"` : ""}`);
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log(`landing ${origin}/`);
|
|
148
|
+
console.log(`account ${origin}/account`);
|
|
149
|
+
console.log(`policy ${origin}/policy`);
|
|
150
|
+
if (adminToken)
|
|
151
|
+
console.log(`admin ${origin}/admin (token: <hidden>)`);
|
|
152
|
+
console.log("");
|
|
153
|
+
serve({ fetch: app.fetch, hostname: host, port });
|
|
154
|
+
}
|
|
155
|
+
main().catch((err) => {
|
|
156
|
+
console.error(`fatal:`, err);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|