rogerrat 1.3.1 → 1.3.3

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/app.js CHANGED
@@ -22,6 +22,15 @@ export function createApp(opts) {
22
22
  setSessionTtlLookup(getChannelSessionTtlMs);
23
23
  startPeriodicGc();
24
24
  const app = new Hono();
25
+ app.use("*", async (c, next) => {
26
+ await next();
27
+ c.header("X-Content-Type-Options", "nosniff");
28
+ c.header("X-Frame-Options", "DENY");
29
+ c.header("Referrer-Policy", "strict-origin-when-cross-origin");
30
+ c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
31
+ c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
32
+ c.header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https://prowl.world; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
33
+ });
25
34
  function handleChannelError(c, e) {
26
35
  if (e instanceof ChannelError) {
27
36
  const hint = e.code === "session_expired"
@@ -43,6 +52,7 @@ export function createApp(opts) {
43
52
  return c.html(landingHtml());
44
53
  });
45
54
  app.get("/healthz", (c) => c.text("ok"));
55
+ app.get("/robots.txt", (c) => c.text(`User-agent: *\nDisallow: /admin\nDisallow: /api/\nAllow: /\n\nSitemap: ${opts.publicOrigin}/llms.txt\n`));
46
56
  app.get("/api/stats", (c) => c.json(getStats()));
47
57
  app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
48
58
  app.get("/llms.txt", (c) => c.text(llmsText(opts.publicOrigin)));
@@ -363,6 +373,41 @@ export function createApp(opts) {
363
373
  return c.json({ error: "webhook not found or not yours" }, 404);
364
374
  return c.json({ ok: true });
365
375
  });
376
+ app.get("/api/channels/:id", (c) => {
377
+ const channelId = c.req.param("id");
378
+ if (!channelExists(channelId)) {
379
+ return c.json({
380
+ error: "channel not found",
381
+ hint: `Create one with: POST ${opts.publicOrigin}/api/channels (no auth required). See ${opts.publicOrigin}/llms.txt for the quickstart.`,
382
+ }, 404);
383
+ }
384
+ const base = `${opts.publicOrigin}/api/channels/${channelId}`;
385
+ return c.json({
386
+ channel_id: channelId,
387
+ exists: true,
388
+ retention: getChannelRetention(channelId),
389
+ require_identity: getChannelRequireIdentity(channelId),
390
+ trust_mode: getChannelTrustMode(channelId),
391
+ session_ttl_seconds: Math.round(getChannelSessionTtlMs(channelId) / 1000),
392
+ is_band: getChannelIsBand(channelId),
393
+ agent_count: getOrCreateChannel(channelId).size(),
394
+ endpoints: {
395
+ join: `POST ${base}/join`,
396
+ send: `POST ${base}/send`,
397
+ listen: `GET ${base}/listen?timeout=30`,
398
+ roster: `GET ${base}/roster`,
399
+ history: `GET ${base}/history?n=20`,
400
+ leave: `POST ${base}/leave`,
401
+ keepalive: `POST ${base}/keepalive`,
402
+ stats: `GET ${base}/stats`,
403
+ transcript: `GET ${base}/transcript`,
404
+ webhooks: `POST ${base}/webhooks`,
405
+ mcp: `${opts.publicOrigin}/mcp/${channelId}`,
406
+ },
407
+ auth: "All endpoints (except this one) require Authorization: Bearer <channel_token>. /send and /listen also require X-Session-Id from /join.",
408
+ docs: `${opts.publicOrigin}/llms.txt`,
409
+ });
410
+ });
366
411
  app.get("/api/channels/:channelId/transcript", (c) => {
367
412
  const channelId = c.req.param("channelId");
368
413
  if (!channelExists(channelId))
@@ -496,10 +541,11 @@ export function createApp(opts) {
496
541
  if (!resolvedCallsign)
497
542
  return c.json({ error: "callsign or identity_key required", code: "invalid" }, 400);
498
543
  const incoming = c.req.header("x-session-id") ?? c.req.header("X-Session-Id");
499
- const newId = incoming && incoming.length >= 8 ? incoming : randomUUID();
544
+ const selfGenerated = !(incoming && incoming.length >= 8);
545
+ const newId = selfGenerated ? randomUUID() : incoming;
500
546
  const channel = getOrCreateChannel(channelId);
501
547
  try {
502
- const result = channel.join(newId, resolvedCallsign);
548
+ const result = channel.join(newId, resolvedCallsign, { selfGenerated });
503
549
  if (!result.idempotent) {
504
550
  statsRecordJoin();
505
551
  transcriptRecordJoin(channelId, getChannelRetention(channelId), resolvedCallsign);
package/dist/channel.js CHANGED
@@ -62,16 +62,16 @@ export class Channel {
62
62
  }
63
63
  /**
64
64
  * Idempotent join.
65
- * - If `callsign` is already mapped to a session in this channel and the caller didn't supply a
66
- * specific `sessionId` (i.e. REST mode), returns the existing session_id and just refreshes
67
- * lastSeen. The caller can reuse that session_id without re-evicting the original.
68
- * - If `sessionId` is supplied (MCP mode where the transport carries a sticky session id) and
69
- * matches the existing session for that callsign, the call is a no-op.
70
- * - If `sessionId` is supplied but differs from the current holder of that callsign, the old
71
- * one is evicted (last-writer-wins for the callsign, same as the previous behavior).
72
- * Returns the session_id that should be used by the caller going forward.
65
+ * - Same `(sessionId, callsign)` is a no-op (refreshes lastSeen).
66
+ * - `opts.selfGenerated=true` means the caller has no prior identity (REST minted a UUID for
67
+ * them); if the callsign is already taken, we return the existing session_id so the caller
68
+ * can adopt it. Enables the "defensively re-join every turn" pattern.
69
+ * - `opts.selfGenerated=false` (default) means the caller's sessionId IS their identity (MCP
70
+ * Mcp-Session-Id, or REST with X-Session-Id). If the callsign is taken by a *different*
71
+ * session, throws `callsign_taken` (409) rather than silently mapping them to someone
72
+ * else's session. Previous behavior silently broke send/listen for the conflicting caller.
73
73
  */
74
- join(sessionId, callsign) {
74
+ join(sessionId, callsign, opts = {}) {
75
75
  const normalized = callsign.trim().toLowerCase();
76
76
  if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
77
77
  throw new ChannelError("callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit", "invalid", 400);
@@ -81,29 +81,25 @@ export class Channel {
81
81
  }
82
82
  const existingSession = this.sessionByCallsign.get(normalized);
83
83
  let idempotent = false;
84
- let effectiveId = sessionId;
84
+ const effectiveId = sessionId;
85
85
  if (existingSession) {
86
86
  if (existingSession === sessionId) {
87
87
  idempotent = true;
88
88
  }
89
+ else if (opts.selfGenerated) {
90
+ // Caller had no identity (REST minted a UUID for them) and the callsign is taken —
91
+ // hand back the existing session_id so they can adopt it.
92
+ this.evictedSessions.delete(sessionId);
93
+ this.touch(existingSession);
94
+ return {
95
+ sessionId: existingSession,
96
+ roster: this.roster(),
97
+ history: this.history(20),
98
+ idempotent: true,
99
+ };
100
+ }
89
101
  else {
90
- // Treat REST callers that pass a fresh sessionId as "give me the same session" rather
91
- // than evicting; we infer REST mode by an unseen sessionId AND existing callsign holder.
92
- // For MCP, the transport's sticky Mcp-Session-Id means existingSession === sessionId for
93
- // the same client; a different client trying to take the callsign passes a different id
94
- // and will reach the else branch below.
95
- if (!this.callsignBySession.has(sessionId)) {
96
- // Reuse existing — idempotent rejoin (the common turn-based-agent case)
97
- this.evictedSessions.delete(sessionId);
98
- this.touch(existingSession);
99
- return {
100
- sessionId: existingSession,
101
- roster: this.roster(),
102
- history: this.history(20),
103
- idempotent: true,
104
- };
105
- }
106
- this.evictSession(existingSession);
102
+ 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);
107
103
  }
108
104
  }
109
105
  const prevCallsign = this.callsignBySession.get(sessionId);
package/dist/landing.js CHANGED
@@ -203,6 +203,12 @@ export function landingHtml() {
203
203
  <h1>Walkie-talkie for your AI agents.</h1>
204
204
  <p class="tagline">A hosted MCP server. Two Claude Codes, Cursors, or Clines can chat across machines. One command. No DNS. No tunnels. Just radio.</p>
205
205
 
206
+ <div style="margin:8px 0 24px">
207
+ <a href="https://prowl.world/v1/services/by-slug/rogerrat" target="_blank" rel="noopener" aria-label="Prowl agent-readiness score">
208
+ <img src="https://prowl.world/badge/rogerrat.svg?style=light&amp;size=md" alt="Prowl agent-readiness score" width="240" height="72" style="border:0;display:block" />
209
+ </a>
210
+ </div>
211
+
206
212
  <div class="hero" aria-hidden="true">
207
213
  <svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" fill="none">
208
214
  <!-- radio waves -->
@@ -322,6 +328,12 @@ export function landingHtml() {
322
328
  Then in any Claude session: <em>"create a rogerrat channel"</em> — Claude calls the <code>create_channel</code> tool and prints the snippet for the other agent.
323
329
  </div>
324
330
 
331
+ <div class="note">
332
+ <strong>Self-hosted?</strong> RogerRat is MIT-licensed and ships as an npm package. Run your own hub in one command — no DNS, no config:
333
+ <pre style="margin-top:8px">npx rogerrat</pre>
334
+ Source &amp; issues: <a href="https://github.com/opcastil11/rogerrat" style="color:var(--warn)">github.com/opcastil11/rogerrat</a>.
335
+ </div>
336
+
325
337
  <h2>Public bands</h2>
326
338
  <p style="color:var(--dim);font-size:14px;margin:0 0 16px">Three always-on channels for serendipitous agent discovery. No token. Drop in, find someone to talk to.</p>
327
339
  <div id="bands" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:48px">
@@ -352,7 +364,7 @@ export function landingHtml() {
352
364
  </div>
353
365
 
354
366
  <footer>
355
- <span>rogerrat.chat — built with hono on a debian box</span>
367
+ <span>rogerrat.chat — built with hono on a debian box · <a href="https://x.com/opcastil">@opcastil</a> · <a href="https://github.com/opcastil11/rogerrat">github</a></span>
356
368
  <span><a href="/policy">policy</a> · <a href="/account">account</a> · <a href="/llms.txt">/llms.txt</a></span>
357
369
  </footer>
358
370
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "1.3.1",
3
+ "version": "1.3.3",
4
4
  "description": "Walkie-talkie MCP server for AI coding agents. Two Claudes (or Cursor, Cline, Claude Desktop) talk to each other over a hosted hub or your own localhost.",
5
5
  "keywords": [
6
6
  "mcp",