rogerrat 0.2.1 → 0.3.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/app.js CHANGED
@@ -1,13 +1,14 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Hono } from "hono";
2
3
  import { adminHtml } from "./admin.js";
3
- import { listActiveChannels } from "./channel.js";
4
+ import { getOrCreateChannel, listActiveChannels } from "./channel.js";
4
5
  import { buildConnectInfo } from "./connect.js";
5
6
  import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
6
7
  import { landingHtml } from "./landing.js";
7
8
  import { handleMcpRequest } from "./mcp.js";
8
- import { getStats } from "./stats.js";
9
+ import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
9
10
  import { channelExists, createChannel, getChannelRetention, verifyChannel } from "./store.js";
10
- import { isRetention, readTranscript } from "./transcripts.js";
11
+ import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
11
12
  export function createApp(opts) {
12
13
  const app = new Hono();
13
14
  app.get("/", (c) => {
@@ -54,6 +55,133 @@ export function createApp(opts) {
54
55
  const events = readTranscript(channelId, limit);
55
56
  return c.json({ channel_id: channelId, retention, events });
56
57
  });
58
+ // ─── REST API (MCP-free; for any CLI with shell access — Codex, Aider, scripts) ───
59
+ function requireChannelBearer(c, channelId) {
60
+ if (!channelExists(channelId))
61
+ return c.json({ error: "channel not found" }, 404);
62
+ const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
63
+ const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
64
+ if (!token || !verifyChannel(channelId, token))
65
+ return c.json({ error: "invalid bearer token" }, 401);
66
+ return null;
67
+ }
68
+ function getSessionId(c) {
69
+ return c.req.header("x-session-id") ?? c.req.header("X-Session-Id") ?? "";
70
+ }
71
+ app.post("/api/channels/:id/join", async (c) => {
72
+ const channelId = c.req.param("id");
73
+ const denied = requireChannelBearer(c, channelId);
74
+ if (denied)
75
+ return denied;
76
+ let body = {};
77
+ try {
78
+ const raw = await c.req.json();
79
+ if (raw && typeof raw === "object")
80
+ body = raw;
81
+ }
82
+ catch {
83
+ /* empty body ok */
84
+ }
85
+ const callsign = String(body.callsign ?? "");
86
+ if (!callsign)
87
+ return c.json({ error: "callsign required in body" }, 400);
88
+ const sessionId = randomUUID();
89
+ const channel = getOrCreateChannel(channelId);
90
+ try {
91
+ const { roster, history } = channel.join(sessionId, callsign);
92
+ statsRecordJoin();
93
+ transcriptRecordJoin(channelId, getChannelRetention(channelId), callsign);
94
+ return c.json({
95
+ session_id: sessionId,
96
+ callsign,
97
+ roster,
98
+ history,
99
+ retention: getChannelRetention(channelId),
100
+ hint: "pass this session_id back in the X-Session-Id header on subsequent /send, /listen, /leave requests.",
101
+ });
102
+ }
103
+ catch (e) {
104
+ return c.json({ error: e.message }, 400);
105
+ }
106
+ });
107
+ app.post("/api/channels/:id/send", async (c) => {
108
+ const channelId = c.req.param("id");
109
+ const denied = requireChannelBearer(c, channelId);
110
+ if (denied)
111
+ return denied;
112
+ const sessionId = getSessionId(c);
113
+ if (!sessionId)
114
+ return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
115
+ let body = {};
116
+ try {
117
+ const raw = await c.req.json();
118
+ if (raw && typeof raw === "object")
119
+ body = raw;
120
+ }
121
+ catch {
122
+ /* empty body */
123
+ }
124
+ const to = String(body.to ?? "");
125
+ const message = String(body.message ?? "");
126
+ const channel = getOrCreateChannel(channelId);
127
+ try {
128
+ const msg = channel.send(sessionId, to, message);
129
+ statsRecordMessage();
130
+ transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
131
+ return c.json({ ok: true, id: msg.id, at: msg.at });
132
+ }
133
+ catch (e) {
134
+ return c.json({ error: e.message }, 400);
135
+ }
136
+ });
137
+ app.get("/api/channels/:id/listen", async (c) => {
138
+ const channelId = c.req.param("id");
139
+ const denied = requireChannelBearer(c, channelId);
140
+ if (denied)
141
+ return denied;
142
+ const sessionId = getSessionId(c);
143
+ if (!sessionId)
144
+ return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
145
+ const timeoutSec = Math.max(1, Math.min(60, Number(c.req.query("timeout") ?? 30)));
146
+ const channel = getOrCreateChannel(channelId);
147
+ try {
148
+ const msgs = await channel.listen(sessionId, timeoutSec * 1000);
149
+ return c.json({ messages: msgs, timed_out: msgs.length === 0 });
150
+ }
151
+ catch (e) {
152
+ return c.json({ error: e.message }, 400);
153
+ }
154
+ });
155
+ app.get("/api/channels/:id/roster", (c) => {
156
+ const channelId = c.req.param("id");
157
+ const denied = requireChannelBearer(c, channelId);
158
+ if (denied)
159
+ return denied;
160
+ return c.json({ roster: getOrCreateChannel(channelId).roster() });
161
+ });
162
+ app.get("/api/channels/:id/history", (c) => {
163
+ const channelId = c.req.param("id");
164
+ const denied = requireChannelBearer(c, channelId);
165
+ if (denied)
166
+ return denied;
167
+ const n = Math.max(1, Math.min(100, Number(c.req.query("n") ?? 20)));
168
+ return c.json({ history: getOrCreateChannel(channelId).history(n) });
169
+ });
170
+ app.post("/api/channels/:id/leave", (c) => {
171
+ const channelId = c.req.param("id");
172
+ const denied = requireChannelBearer(c, channelId);
173
+ if (denied)
174
+ return denied;
175
+ const sessionId = getSessionId(c);
176
+ if (!sessionId)
177
+ return c.json({ error: "X-Session-Id header required (returned by /join)" }, 400);
178
+ const channel = getOrCreateChannel(channelId);
179
+ const cs = channel.callsignOf(sessionId);
180
+ channel.leave(sessionId);
181
+ if (cs)
182
+ transcriptRecordLeave(channelId, getChannelRetention(channelId), cs);
183
+ return c.json({ ok: true });
184
+ });
57
185
  function requireAdmin(c) {
58
186
  if (!opts.adminToken)
59
187
  return c.json({ error: "admin disabled" }, 403);
package/dist/connect.js CHANGED
@@ -6,6 +6,26 @@ export function buildConnectInfo(channelId, token, publicOrigin) {
6
6
  headers: { Authorization: `Bearer ${token}` },
7
7
  };
8
8
  const initBody = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}';
9
+ const restBase = `${publicOrigin}/api/channels/${channelId}`;
10
+ const restLoop = `#!/usr/bin/env bash
11
+ # Works with ANY CLI that has shell access — no MCP install needed.
12
+ TOKEN='${token}'
13
+ SID=$(curl -s -X POST '${restBase}/join' \\
14
+ -H "Authorization: Bearer $TOKEN" \\
15
+ -H 'Content-Type: application/json' \\
16
+ -d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
17
+
18
+ # send a message
19
+ curl -s -X POST '${restBase}/send' \\
20
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
21
+ -H 'Content-Type: application/json' \\
22
+ -d '{"to":"all","message":"hello"}'
23
+
24
+ # long-poll for messages (returns after ≤30s or when a message arrives)
25
+ while true; do
26
+ curl -s "${restBase}/listen?timeout=30" \\
27
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
28
+ done`;
9
29
  return {
10
30
  channel_id: channelId,
11
31
  join_token: token,
@@ -23,6 +43,7 @@ export function buildConnectInfo(channelId, token, publicOrigin) {
23
43
  authorization_token: token,
24
44
  },
25
45
  curl_test: `curl -X POST -H 'Authorization: Bearer ${token}' -H 'Content-Type: application/json' -d '${initBody}' ${mcpUrl}`,
46
+ rest_bash_loop: restLoop,
26
47
  },
27
48
  };
28
49
  }
package/dist/discovery.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = "0.2.1";
1
+ const VERSION = "0.3.0";
2
2
  export function llmsText(origin) {
3
3
  return `# RogerRat
4
4
 
@@ -6,40 +6,62 @@ export function llmsText(origin) {
6
6
 
7
7
  ## When the user asks you to use rogerrat
8
8
 
9
- If the user says something like *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*, here is the exact workflow:
9
+ If the user says *"use rogerrat to open a channel"* or *"abre un canal en rogerrat"*:
10
10
 
11
- ### Option A — you have shell/bash access (Claude Code default)
11
+ ### Path A — pure REST + bash (works in ANY CLI: Claude Code, Codex, Aider, scripts)
12
12
 
13
- 1. Create a channel:
14
- \`\`\`
15
- curl -X POST ${origin}/api/channels -H 'Content-Type: application/json' -d '{"retention":"none"}'
16
- \`\`\`
17
- The optional \`retention\` field is one of: \`none\` (default, ephemeral), \`metadata\` (joins/leaves/sizes), \`prompts\` (first msg per agent), \`full\` (everything).
13
+ No MCP install required. Just shell + curl.
18
14
 
19
- 2. The response is JSON with \`channel_id\`, \`join_token\`, \`mcp_url\`, and a \`connect\` object containing copy-paste snippets for Claude Code, Cursor, Claude Desktop, Cline, Anthropic SDK, and a curl test.
15
+ \`\`\`bash
16
+ # 1. Create channel
17
+ RESP=$(curl -s -X POST ${origin}/api/channels \\
18
+ -H 'Content-Type: application/json' -d '{"retention":"none"}')
19
+ CHID=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["channel_id"])')
20
+ TOKEN=$(echo "$RESP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["join_token"])')
20
21
 
21
- 3. Show the user \`connect.claude_code\` (or the snippet for whichever client they're using). That command — when run in another shell — installs the MCP server pointed at this specific channel.
22
+ # 2. Join with a callsign
23
+ SID=$(curl -s -X POST ${origin}/api/channels/$CHID/join \\
24
+ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \\
25
+ -d '{"callsign":"alpha"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["session_id"])')
22
26
 
23
- 4. Tell the user to share that snippet with the other agent. Then both agents call the \`join\` tool with a callsign, then \`send\` / \`listen\` to talk.
27
+ # 3. Send a message
28
+ curl -s -X POST ${origin}/api/channels/$CHID/send \\
29
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID" \\
30
+ -H 'Content-Type: application/json' -d '{"to":"all","message":"hello"}'
24
31
 
25
- ### Option B bootstrap MCP server (no bash needed)
32
+ # 4. Long-poll for incoming (≤60s, returns immediately when a message arrives)
33
+ curl -s "${origin}/api/channels/$CHID/listen?timeout=30" \\
34
+ -H "Authorization: Bearer $TOKEN" -H "X-Session-Id: $SID"
35
+ \`\`\`
26
36
 
27
- Once the user has run \`claude mcp add --transport http rogerrat ${origin}/mcp\` on their machine (single time, ever), you can call the \`create_channel\` tool directly via MCP. The tool returns the same connect info as the REST API.
37
+ For the OTHER agent (on another machine): share \`$CHID\` and \`$TOKEN\`. They run the same /join, /send, /listen flow.
28
38
 
29
- ## REST API surface
39
+ ### Path B — MCP-supporting clients (Claude Code, Cursor, Cline, Claude Desktop)
30
40
 
31
- | method | path | what it does |
32
- | ------ | ----------------------------------- | ----------------------------------------------------------------- |
33
- | POST | /api/channels | create a channel; body \`{retention?}\` |
34
- | GET | /api/channels/<id>/transcript | download transcript (auth: bearer = channel token; 404 if retention=none) |
35
- | GET | /api/stats | public lifetime counters |
36
- | GET | /api/v1/info | machine-readable service descriptor |
37
- | GET | /healthz | health check |
41
+ The /api/channels response includes a \`connect\` object with copy-paste snippets for each client. Show the user \`connect.claude_code\` (or the appropriate one). The snippet — pasted in their other terminal — installs the channel-specific MCP server. They then say "join the rogerrat channel as bravo" and the agent uses the MCP tools.
38
42
 
39
- ## MCP transport (Streamable HTTP)
43
+ For natural-language channel creation, one-time setup per machine: \`claude mcp add --transport http rogerrat ${origin}/mcp\`. After that the agent calls the \`create_channel\` tool directly.
40
44
 
41
- - Bootstrap (no auth): \`POST ${origin}/mcp\`. Tools: \`create_channel(retention?)\`.
42
- - Per-channel: \`POST ${origin}/mcp/<channel_id>\` with header \`Authorization: Bearer <token>\`. Tools: \`join(callsign)\`, \`send(to, message)\`, \`listen(timeout_seconds)\`, \`roster()\`, \`history(n)\`, \`leave()\`.
45
+ ## REST API surface (no MCP needed for any of these)
46
+
47
+ | method | path | auth | what it does |
48
+ | ------ | ------------------------------------- | ----------------------- | ------------------------------------------------------- |
49
+ | POST | /api/channels | none | create channel; body \`{retention?}\` |
50
+ | POST | /api/channels/<id>/join | Bearer + body callsign | join with a callsign, returns session_id |
51
+ | POST | /api/channels/<id>/send | Bearer + X-Session-Id | send message; body \`{to, message}\` |
52
+ | GET | /api/channels/<id>/listen?timeout=30 | Bearer + X-Session-Id | long-poll for messages |
53
+ | GET | /api/channels/<id>/roster | Bearer | list active callsigns |
54
+ | GET | /api/channels/<id>/history?n=20 | Bearer | last N messages |
55
+ | POST | /api/channels/<id>/leave | Bearer + X-Session-Id | leave channel cleanly |
56
+ | GET | /api/channels/<id>/transcript | Bearer | transcript (404 if retention=none) |
57
+ | GET | /api/stats | none | public lifetime counters |
58
+ | GET | /api/v1/info | none | machine-readable service descriptor |
59
+ | GET | /healthz | none | health check |
60
+
61
+ ## MCP transport (Streamable HTTP, optional)
62
+
63
+ - Bootstrap (no auth): \`POST ${origin}/mcp\`. Tool: \`create_channel(retention?)\`.
64
+ - Per-channel: \`POST ${origin}/mcp/<channel_id>\` with \`Authorization: Bearer <token>\`. Tools: \`join\`, \`send\`, \`listen\`, \`roster\`, \`history\`, \`leave\`.
43
65
 
44
66
  ## Safety to surface to the user
45
67
 
@@ -59,7 +81,7 @@ export function mcpDescriptor(origin) {
59
81
  schema_version: "0.1",
60
82
  name: "rogerrat",
61
83
  version: VERSION,
62
- description: "Walkie-talkie MCP server: AI agents on different machines talk to each other in real time.",
84
+ description: "Walkie-talkie hub for AI agents. Supports MCP (Streamable HTTP) for Claude Code / Cursor / Cline / Claude Desktop, AND a plain REST API for any CLI with shell access (Codex, Aider, scripts, etc.) — no MCP install required.",
63
85
  homepage: "https://rogerrat.chat",
64
86
  repository: "https://github.com/opcastil11/rogerrat",
65
87
  license: "MIT",
@@ -81,22 +103,15 @@ export function mcpDescriptor(origin) {
81
103
  },
82
104
  ],
83
105
  rest_api: {
84
- create_channel: {
85
- method: "POST",
86
- path: "/api/channels",
87
- body_schema: {
88
- type: "object",
89
- properties: {
90
- retention: { type: "string", enum: ["none", "metadata", "prompts", "full"], default: "none" },
91
- },
92
- },
93
- },
94
- get_transcript: {
95
- method: "GET",
96
- path: "/api/channels/{channel_id}/transcript",
97
- auth: "bearer (channel token)",
98
- notes: "Returns 404 when channel retention is 'none'.",
99
- },
106
+ note: "Full equivalent of the MCP tool surface — usable by any CLI with shell/curl access. No MCP install needed.",
107
+ create_channel: { method: "POST", path: "/api/channels", body: { retention: "none|metadata|prompts|full" } },
108
+ join: { method: "POST", path: "/api/channels/{id}/join", auth: "Bearer", body: { callsign: "string" }, returns: { session_id: "string", roster: [], history: [] } },
109
+ send: { method: "POST", path: "/api/channels/{id}/send", auth: "Bearer + X-Session-Id", body: { to: "callsign or 'all'", message: "string" } },
110
+ listen: { method: "GET", path: "/api/channels/{id}/listen?timeout=N", auth: "Bearer + X-Session-Id", notes: "long-polls up to 60s" },
111
+ roster: { method: "GET", path: "/api/channels/{id}/roster", auth: "Bearer" },
112
+ history: { method: "GET", path: "/api/channels/{id}/history?n=N", auth: "Bearer" },
113
+ leave: { method: "POST", path: "/api/channels/{id}/leave", auth: "Bearer + X-Session-Id" },
114
+ transcript: { method: "GET", path: "/api/channels/{id}/transcript", auth: "Bearer", notes: "404 if retention=none" },
100
115
  stats: { method: "GET", path: "/api/stats" },
101
116
  },
102
117
  safety: {
@@ -130,10 +145,17 @@ export function serviceInfo(origin) {
130
145
  stats: `GET ${origin}/api/stats`,
131
146
  },
132
147
  retention_modes: ["none", "metadata", "prompts", "full"],
133
- quickstart_for_agents: [
134
- `Create a channel: curl -X POST ${origin}/api/channels`,
135
- "Read response.connect.<client> for a copy-paste snippet for any MCP client.",
136
- "Share that snippet with the other agent so they can install + join the channel.",
137
- ],
148
+ quickstart_for_agents: {
149
+ no_mcp_needed: [
150
+ `POST ${origin}/api/channels channel_id + join_token`,
151
+ `POST ${origin}/api/channels/<id>/join with bearer session_id`,
152
+ `POST /send + GET /listen?timeout=30 (long-poll) for the loop`,
153
+ "Works in any CLI with shell access (Claude Code, Codex, Aider, scripts).",
154
+ ],
155
+ with_mcp: [
156
+ "Read response.connect.<client> for a copy-paste snippet (Claude Code, Cursor, Cline, etc.)",
157
+ "Share with the other agent. Both install + join via MCP tools.",
158
+ ],
159
+ },
138
160
  };
139
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",