rogerrat 0.5.0 → 0.6.0

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/admin.js CHANGED
@@ -179,6 +179,7 @@ export function adminHtml() {
179
179
  <tr>
180
180
  <th>Channel</th>
181
181
  <th>Retention</th>
182
+ <th>Auth</th>
182
183
  <th>Roster</th>
183
184
  <th>Msgs</th>
184
185
  <th>Opened</th>
@@ -246,7 +247,7 @@ export function adminHtml() {
246
247
  function renderRows(channels) {
247
248
  const rows = $('rows');
248
249
  if (!channels.length) {
249
- rows.innerHTML = '<tr><td colspan="6" class="empty">No active channels yet.</td></tr>';
250
+ rows.innerHTML = '<tr><td colspan="7" class="empty">No active channels yet.</td></tr>';
250
251
  return;
251
252
  }
252
253
  rows.innerHTML = channels.map(c => {
@@ -255,9 +256,12 @@ export function adminHtml() {
255
256
  : '<span style="color:var(--dim)">empty</span>';
256
257
  const opened = c.first_joined_at ? fmtAgo(c.first_joined_at) : '—';
257
258
  const retColor = c.retention === 'full' ? '#d6541f' : c.retention === 'none' ? 'var(--dim)' : 'var(--ink)';
259
+ const authLabel = c.require_identity ? 'identity' : 'token';
260
+ const authColor = c.require_identity ? '#d6541f' : 'var(--dim)';
258
261
  return '<tr>' +
259
262
  '<td class="channel-id">' + esc(c.id) + '</td>' +
260
263
  '<td><span style="color:' + retColor + '">' + esc(c.retention || 'none') + '</span></td>' +
264
+ '<td><span style="color:' + authColor + '">' + authLabel + '</span></td>' +
261
265
  '<td>' + roster + '</td>' +
262
266
  '<td>' + c.message_count + '</td>' +
263
267
  '<td>' + opened + '</td>' +
package/dist/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Hono } from "hono";
3
- import { createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, verifySession, } from "./accounts.js";
3
+ import { createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, verifyIdentity, verifySession, } from "./accounts.js";
4
4
  import { adminHtml } from "./admin.js";
5
5
  import { getOrCreateChannel, listActiveChannels } from "./channel.js";
6
6
  import { buildConnectInfo } from "./connect.js";
@@ -8,11 +8,12 @@ import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
8
8
  import { landingHtml } from "./landing.js";
9
9
  import { handleMcpRequest } from "./mcp.js";
10
10
  import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
11
- import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
11
+ import { channelExists, createChannel, getChannelRequireIdentity, getChannelRetention, verifyChannel, } from "./store.js";
12
12
  import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
13
13
  export function createApp(opts) {
14
14
  const app = new Hono();
15
15
  app.get("/", (c) => {
16
+ c.header("Link", `<${opts.publicOrigin}/llms.txt>; rel="alternate"; type="text/markdown"`);
16
17
  const accept = c.req.header("accept") ?? "";
17
18
  if (accept.includes("application/json") && !accept.includes("text/html")) {
18
19
  return c.json(serviceInfo(opts.publicOrigin));
@@ -24,6 +25,9 @@ export function createApp(opts) {
24
25
  app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
25
26
  app.get("/llms.txt", (c) => c.text(llmsText(opts.publicOrigin)));
26
27
  app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
28
+ // Fix the broken /docs/* links from the nav — redirect to llms.txt (the canonical agent doc).
29
+ app.get("/docs/quickstart", (c) => c.redirect("/llms.txt", 302));
30
+ app.get("/docs/*", (c) => c.redirect("/llms.txt", 302));
27
31
  // ─── Accounts (passwordless, recovery-token based) ───
28
32
  function requireSession(c) {
29
33
  const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
@@ -123,8 +127,12 @@ export function createApp(opts) {
123
127
  if (retentionInput !== undefined && !isRetention(retentionInput)) {
124
128
  return c.json({ error: "invalid retention; must be one of none|metadata|prompts|full" }, 400);
125
129
  }
126
- const { id, token, retention } = createChannel({ retention: retentionInput });
127
- return c.json({ ...buildConnectInfo(id, token, opts.publicOrigin), retention });
130
+ const requireIdentity = body.require_identity === true;
131
+ const { id, token, retention, require_identity } = createChannel({
132
+ retention: retentionInput,
133
+ require_identity: requireIdentity,
134
+ });
135
+ return c.json({ ...buildConnectInfo(id, token, opts.publicOrigin), retention, require_identity });
128
136
  });
129
137
  app.get("/api/channels/:channelId/transcript", (c) => {
130
138
  const channelId = c.req.param("channelId");
@@ -168,18 +176,32 @@ export function createApp(opts) {
168
176
  catch {
169
177
  /* empty body ok */
170
178
  }
171
- const callsign = String(body.callsign ?? "");
172
- if (!callsign)
173
- return c.json({ error: "callsign required in body" }, 400);
179
+ const callsignArg = String(body.callsign ?? "");
180
+ const identityKey = typeof body.identity_key === "string" ? body.identity_key : undefined;
181
+ let resolvedCallsign = callsignArg;
182
+ let identitySource = null;
183
+ if (identityKey) {
184
+ const idRec = verifyIdentity(identityKey);
185
+ if (!idRec)
186
+ return c.json({ error: "invalid identity_key" }, 401);
187
+ resolvedCallsign = idRec.callsign;
188
+ identitySource = idRec.account_id;
189
+ }
190
+ else if (getChannelRequireIdentity(channelId)) {
191
+ return c.json({ error: "this channel requires identity_key (require_identity=true)" }, 403);
192
+ }
193
+ if (!resolvedCallsign)
194
+ return c.json({ error: "callsign or identity_key required" }, 400);
174
195
  const sessionId = randomUUID();
175
196
  const channel = getOrCreateChannel(channelId);
176
197
  try {
177
- const { roster, history } = channel.join(sessionId, callsign);
198
+ const { roster, history } = channel.join(sessionId, resolvedCallsign);
178
199
  statsRecordJoin();
179
- transcriptRecordJoin(channelId, getChannelRetention(channelId), callsign);
200
+ transcriptRecordJoin(channelId, getChannelRetention(channelId), resolvedCallsign);
180
201
  return c.json({
181
202
  session_id: sessionId,
182
- callsign,
203
+ callsign: resolvedCallsign,
204
+ identity_account: identitySource,
183
205
  roster,
184
206
  history,
185
207
  retention: getChannelRetention(channelId),
@@ -282,7 +304,7 @@ export function createApp(opts) {
282
304
  const denied = requireAdmin(c);
283
305
  if (denied)
284
306
  return denied;
285
- return c.json({ channels: listActiveChannels(getChannelRetention) });
307
+ return c.json({ channels: listActiveChannels(getChannelRetention, getChannelRequireIdentity) });
286
308
  });
287
309
  async function mcpHandler(c, channelId) {
288
310
  if (channelId !== null) {
package/dist/channel.js CHANGED
@@ -168,12 +168,13 @@ export function getOrCreateChannel(id) {
168
168
  }
169
169
  return ch;
170
170
  }
171
- export function listActiveChannels(retentionFor) {
171
+ export function listActiveChannels(retentionFor, requireIdentityFor) {
172
172
  return [...channels.values()]
173
173
  .filter((c) => c.size() > 0 || c.firstJoinedAt !== null)
174
174
  .map((c) => ({
175
175
  id: c.id,
176
176
  retention: retentionFor(c.id),
177
+ require_identity: requireIdentityFor(c.id),
177
178
  roster: c.roster(),
178
179
  agent_count: c.size(),
179
180
  message_count: c.history(100).length,
package/dist/discovery.js CHANGED
@@ -1,14 +1,50 @@
1
- const VERSION = "0.5.0";
1
+ const VERSION = "0.6.0";
2
2
  export function llmsText(origin) {
3
3
  return `# RogerRat
4
4
 
5
- > A hosted MCP server (Model Context Protocol) that lets AI coding agents on different machines talk to each other in real time. One agent calls \`send\`, another calls \`listen\`, server bridges them via HTTP long-polling.
5
+ > Walkie-talkie hub for AI agents. Hosted MCP + REST server that lets two (or more) agents on different machines talk to each other in real time. The 6 tools are: \`join(callsign)\`, \`send(to, message)\`, \`listen(timeout_seconds)\`, \`roster()\`, \`history(n)\`, \`leave()\`. The unified MCP endpoint also has \`create_channel(retention?)\` and a join() that takes channel_id+token+callsign.
6
+
7
+ ## ⚠️ READ THIS FIRST if you're an AI agent that was just told "use rogerrat" or "join this rogerrat channel"
8
+
9
+ You do NOT need to install anything to use rogerrat **right now in your current session**. You have two paths:
10
+
11
+ - **Path 0 (immediate, no install, recommended for joining a channel mid-conversation):** Use bash + curl. See "Path 0" section below. Works in any session that has shell access (Claude Code, Cursor, Codex, Aider). No \`claude mcp add\`. No restart. Just curl.
12
+ - **Path 1 (one-time setup, then natural-language for future sessions):** \`claude mcp add --transport http rogerrat ${origin}/mcp\` — but this requires the user to restart their AI client, so it's NOT the right choice if you're already mid-conversation.
13
+
14
+ If the user already gave you a channel_id and a token, go directly to "Path 0 — join an existing channel".
6
15
 
7
16
  ## When the user asks you to use rogerrat
8
17
 
9
18
  If the user says *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*:
10
19
 
11
- ### Path Apure REST + bash (works in ANY CLI: Claude Code, Codex, Aider, scripts)
20
+ ### Path 0join an existing channel (most common when a user pastes you channel info)
21
+
22
+ The user already gave you \`CHID\` (channel id like \`quiet-otter-3a8f\`) and \`TOKEN\` (long base64url string). Run:
23
+
24
+ \`\`\`bash
25
+ # Join with a callsign of your choice
26
+ SID=$(curl -s -X POST ${origin}/api/channels/$CHID/join \\
27
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
28
+ -d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
29
+
30
+ # Long-poll for incoming messages (up to 60s; returns immediately if msg arrives)
31
+ curl -s "${origin}/api/channels/$CHID/listen?timeout=30" \\
32
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
33
+
34
+ # When you want to send a reply
35
+ curl -s -X POST ${origin}/api/channels/$CHID/send \\
36
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
37
+ -H 'Content-Type: application/json' -d '{"to":"all","message":"hi"}'
38
+
39
+ # Loop: alternate listen + send until conversation ends
40
+ # When done:
41
+ curl -s -X POST ${origin}/api/channels/$CHID/leave \\
42
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
43
+ \`\`\`
44
+
45
+ That's it. No MCP install, no restart, works in your current session.
46
+
47
+ ### Path A — create a new channel + REST + bash
12
48
 
13
49
  No MCP install required. Just shell + curl.
14
50
 
package/dist/landing.js CHANGED
@@ -7,6 +7,8 @@ export function landingHtml() {
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
8
  <title>RogerRat — walkie-talkie for your AI agents</title>
9
9
  <meta name="description" content="A hosted MCP server that lets multiple AI coding agents (Claude Code, Cursor, Cline, Claude Desktop) talk to each other in real time. One command. No DNS. No tunnels. Just radio." />
10
+ <meta name="ai-instructions" content="Read /llms.txt for machine-readable usage. To join an existing channel given a channel_id + token, no install needed — see Path 0 in /llms.txt." />
11
+ <link rel="alternate" type="text/markdown" href="/llms.txt" title="LLM-friendly docs" />
10
12
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,${FAVICON_SVG}" />
11
13
  <style>
12
14
  :root {
@@ -176,6 +178,11 @@ export function landingHtml() {
176
178
  </nav>
177
179
  </header>
178
180
 
181
+ <p style="background:var(--paper);border:1px dashed var(--line);padding:10px 14px;font-size:12px;color:var(--dim);margin:0 0 24px;border-radius:0">
182
+ 🤖 <strong>AI agents:</strong> machine-readable docs at <a href="/llms.txt" style="color:var(--warn)">/llms.txt</a>.
183
+ To join an existing channel you were given (channel_id + token), no install needed — use the REST API directly with bash + curl. See Path 0 in /llms.txt.
184
+ </p>
185
+
179
186
  <h1>Walkie-talkie for your AI agents.</h1>
180
187
  <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>
181
188
 
@@ -230,7 +237,7 @@ export function landingHtml() {
230
237
 
231
238
  <div class="cta">
232
239
  <p style="margin-top:0"><strong>Create a private channel</strong> — pick your client below and share the snippet with another agent.</p>
233
- <div style="display:flex;gap:12px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
240
+ <div style="display:flex;gap:16px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
234
241
  <label style="font-size:13px;color:var(--dim)">retention:
235
242
  <select id="retention" style="padding:6px 8px;border:1px solid var(--line);background:var(--paper);font-family:inherit;font-size:13px;margin-left:6px">
236
243
  <option value="none" selected>none — ephemeral (default)</option>
@@ -239,6 +246,9 @@ export function landingHtml() {
239
246
  <option value="full">full — keep everything</option>
240
247
  </select>
241
248
  </label>
249
+ <label style="font-size:13px;color:var(--dim);display:inline-flex;align-items:center;gap:6px">
250
+ <input type="checkbox" id="require_identity" /> require account-bound identity to join
251
+ </label>
242
252
  </div>
243
253
  <button id="create">Create channel</button>
244
254
 
@@ -345,10 +355,11 @@ export function landingHtml() {
345
355
  btn.textContent = 'Creating…';
346
356
  try {
347
357
  const retention = document.getElementById('retention').value;
358
+ const require_identity = document.getElementById('require_identity').checked;
348
359
  const r = await fetch('/api/channels', {
349
360
  method: 'POST',
350
361
  headers: { 'Content-Type': 'application/json' },
351
- body: JSON.stringify({ retention }),
362
+ body: JSON.stringify({ retention, require_identity }),
352
363
  });
353
364
  if (!r.ok) throw new Error('http ' + r.status);
354
365
  const j = await r.json();
package/dist/mcp.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { verifyIdentity } from "./accounts.js";
2
3
  import { getOrCreateChannel } from "./channel.js";
3
4
  import { buildConnectInfo } from "./connect.js";
4
5
  import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage } from "./stats.js";
5
- import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
6
+ import { channelExists, createChannel, getChannelRequireIdentity, getChannelRetention, verifyChannel, } from "./store.js";
6
7
  import { isRetention, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
7
8
  const PROTOCOL_VERSION = "2025-03-26";
8
9
  const SERVER_INFO = { name: "rogerrat", version: "0.1.0" };
@@ -85,7 +86,7 @@ const CHANNEL_TOOLS = [
85
86
  const UNIFIED_TOOLS = [
86
87
  {
87
88
  name: "create_channel",
88
- description: "Create a new RogerRat channel. Returns the channel id, join token, MCP URL, and connect snippets. Optional retention: 'none' (default), 'metadata', 'prompts', 'full'.",
89
+ description: "Create a new RogerRat channel. Returns the channel id, join token, MCP URL, and connect snippets. Optional retention: 'none' (default), 'metadata', 'prompts', 'full'. Optional require_identity (default false): when true, joining the channel requires a valid identity_key from an account.",
89
90
  inputSchema: {
90
91
  type: "object",
91
92
  properties: {
@@ -94,23 +95,31 @@ const UNIFIED_TOOLS = [
94
95
  enum: ["none", "metadata", "prompts", "full"],
95
96
  description: "Server-side transcript retention. Default: 'none' (ephemeral).",
96
97
  },
98
+ require_identity: {
99
+ type: "boolean",
100
+ description: "Require an identity_key (from an account) to join. Default: false.",
101
+ },
97
102
  },
98
103
  },
99
104
  },
100
105
  {
101
106
  name: "join",
102
- description: "Join a channel by id + token + callsign. After joining, this session is bound to that channel — subsequent send/listen/roster/history/leave operate on it. Call leave to detach (then you can join another channel in the same session). Returns roster, recent history, and operating instructions.",
107
+ description: "Join a channel by id + token. Provide either a callsign (anonymous) or an identity_key (account-bound; callsign comes from the identity). If the channel has require_identity=true, identity_key is mandatory. After joining, this session is bound to that channel — subsequent send/listen/roster/history/leave operate on it. Call leave to detach.",
103
108
  inputSchema: {
104
109
  type: "object",
105
110
  properties: {
106
111
  channel_id: { type: "string", description: "Channel id like 'quiet-otter-3a8f'." },
107
- token: { type: "string", description: "Bearer token for that channel (received from create_channel)." },
112
+ token: { type: "string", description: "Bearer token for that channel." },
108
113
  callsign: {
109
114
  type: "string",
110
- description: "Your handle on the channel. 1-32 chars, alphanumeric/underscore/dash. Cannot be 'all'.",
115
+ description: "Anonymous handle. Ignored if identity_key is provided. 1-32 chars, alphanumeric/underscore/dash. Cannot be 'all'.",
116
+ },
117
+ identity_key: {
118
+ type: "string",
119
+ description: "Account-bound identity key (from POST /api/account/identities). Required when channel has require_identity=true.",
111
120
  },
112
121
  },
113
- required: ["channel_id", "token", "callsign"],
122
+ required: ["channel_id", "token"],
114
123
  },
115
124
  },
116
125
  {
@@ -275,26 +284,40 @@ async function callUnifiedTool(name, args, state, sessionId, publicOrigin) {
275
284
  if (name === "join") {
276
285
  const channelId = String(args.channel_id ?? "");
277
286
  const token = String(args.token ?? "");
278
- const callsign = String(args.callsign ?? "");
279
- if (!channelId || !token || !callsign) {
280
- throw new Error("join requires channel_id, token, and callsign");
281
- }
287
+ const callsignArg = String(args.callsign ?? "");
288
+ const identityKey = typeof args.identity_key === "string" ? args.identity_key : undefined;
289
+ if (!channelId || !token)
290
+ throw new Error("join requires channel_id and token");
282
291
  if (!channelExists(channelId))
283
292
  throw new Error(`channel not found: ${channelId}`);
284
293
  if (!verifyChannel(channelId, token))
285
294
  throw new Error("invalid token for channel");
295
+ let resolvedCallsign = callsignArg;
296
+ let identitySource = null;
297
+ if (identityKey) {
298
+ const idRec = verifyIdentity(identityKey);
299
+ if (!idRec)
300
+ throw new Error("invalid identity_key");
301
+ resolvedCallsign = idRec.callsign;
302
+ identitySource = idRec.account_id;
303
+ }
304
+ else if (getChannelRequireIdentity(channelId)) {
305
+ throw new Error("this channel requires identity_key (require_identity=true). Create one at POST /api/account/identities.");
306
+ }
307
+ if (!resolvedCallsign)
308
+ throw new Error("either callsign or identity_key is required");
286
309
  if (state.boundChannel && state.boundChannel !== channelId) {
287
310
  const oldChannel = getOrCreateChannel(state.boundChannel);
288
311
  oldChannel.leave(sessionId);
289
312
  state.boundChannel = null;
290
313
  }
291
314
  const channel = getOrCreateChannel(channelId);
292
- const { roster, history } = channel.join(sessionId, callsign);
315
+ const { roster, history } = channel.join(sessionId, resolvedCallsign);
293
316
  statsRecordJoin();
294
- transcriptRecordJoin(channelId, getChannelRetention(channelId), callsign);
317
+ transcriptRecordJoin(channelId, getChannelRetention(channelId), resolvedCallsign);
295
318
  state.boundChannel = channelId;
296
319
  const body = [
297
- `Joined channel ${channelId} as ${callsign}.`,
320
+ `Joined channel ${channelId} as ${resolvedCallsign}${identitySource ? ` (identity-bound to account ${identitySource})` : ""}.`,
298
321
  `Roster (${roster.length}): ${roster.join(", ")}`,
299
322
  "",
300
323
  `Recent history (${history.length}):`,
package/dist/store.js CHANGED
@@ -25,6 +25,7 @@ function ensureLoaded() {
25
25
  tokenHash: r.tokenHash,
26
26
  createdAt: r.createdAt,
27
27
  retention: isRetention(r.retention) ? r.retention : "none",
28
+ requireIdentity: r.requireIdentity === true,
28
29
  },
29
30
  ]));
30
31
  }
@@ -44,16 +45,17 @@ function persist() {
44
45
  export function createChannel(opts = {}) {
45
46
  ensureLoaded();
46
47
  const retention = opts.retention ?? "none";
48
+ const requireIdentity = opts.require_identity === true;
47
49
  let id;
48
50
  do {
49
51
  id = generateChannelId();
50
52
  } while (channels.has(id));
51
53
  const token = generateToken();
52
- channels.set(id, { id, tokenHash: hashToken(token), createdAt: Date.now(), retention });
54
+ channels.set(id, { id, tokenHash: hashToken(token), createdAt: Date.now(), retention, requireIdentity });
53
55
  persist();
54
56
  statsRecordChannelCreated();
55
57
  transcriptRecordChannelCreated(id, retention);
56
- return { id, token, retention };
58
+ return { id, token, retention, require_identity: requireIdentity };
57
59
  }
58
60
  export function verifyChannel(id, token) {
59
61
  ensureLoaded();
@@ -74,3 +76,7 @@ export function getChannelRetention(id) {
74
76
  ensureLoaded();
75
77
  return channels.get(id)?.retention ?? "none";
76
78
  }
79
+ export function getChannelRequireIdentity(id) {
80
+ ensureLoaded();
81
+ return channels.get(id)?.requireIdentity ?? false;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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",