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.
@@ -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
+ });