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 +48 -2
- package/dist/channel.js +23 -27
- package/dist/landing.js +13 -1
- package/package.json +1 -1
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
|
|
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
|
-
* -
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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&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 & 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