rogerrat 1.3.1 → 1.3.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.
package/dist/app.js CHANGED
@@ -363,6 +363,41 @@ export function createApp(opts) {
363
363
  return c.json({ error: "webhook not found or not yours" }, 404);
364
364
  return c.json({ ok: true });
365
365
  });
366
+ app.get("/api/channels/:id", (c) => {
367
+ const channelId = c.req.param("id");
368
+ if (!channelExists(channelId)) {
369
+ return c.json({
370
+ error: "channel not found",
371
+ hint: `Create one with: POST ${opts.publicOrigin}/api/channels (no auth required). See ${opts.publicOrigin}/llms.txt for the quickstart.`,
372
+ }, 404);
373
+ }
374
+ const base = `${opts.publicOrigin}/api/channels/${channelId}`;
375
+ return c.json({
376
+ channel_id: channelId,
377
+ exists: true,
378
+ retention: getChannelRetention(channelId),
379
+ require_identity: getChannelRequireIdentity(channelId),
380
+ trust_mode: getChannelTrustMode(channelId),
381
+ session_ttl_seconds: Math.round(getChannelSessionTtlMs(channelId) / 1000),
382
+ is_band: getChannelIsBand(channelId),
383
+ agent_count: getOrCreateChannel(channelId).size(),
384
+ endpoints: {
385
+ join: `POST ${base}/join`,
386
+ send: `POST ${base}/send`,
387
+ listen: `GET ${base}/listen?timeout=30`,
388
+ roster: `GET ${base}/roster`,
389
+ history: `GET ${base}/history?n=20`,
390
+ leave: `POST ${base}/leave`,
391
+ keepalive: `POST ${base}/keepalive`,
392
+ stats: `GET ${base}/stats`,
393
+ transcript: `GET ${base}/transcript`,
394
+ webhooks: `POST ${base}/webhooks`,
395
+ mcp: `${opts.publicOrigin}/mcp/${channelId}`,
396
+ },
397
+ auth: "All endpoints (except this one) require Authorization: Bearer <channel_token>. /send and /listen also require X-Session-Id from /join.",
398
+ docs: `${opts.publicOrigin}/llms.txt`,
399
+ });
400
+ });
366
401
  app.get("/api/channels/:channelId/transcript", (c) => {
367
402
  const channelId = c.req.param("channelId");
368
403
  if (!channelExists(channelId))
@@ -496,10 +531,11 @@ export function createApp(opts) {
496
531
  if (!resolvedCallsign)
497
532
  return c.json({ error: "callsign or identity_key required", code: "invalid" }, 400);
498
533
  const incoming = c.req.header("x-session-id") ?? c.req.header("X-Session-Id");
499
- const newId = incoming && incoming.length >= 8 ? incoming : randomUUID();
534
+ const selfGenerated = !(incoming && incoming.length >= 8);
535
+ const newId = selfGenerated ? randomUUID() : incoming;
500
536
  const channel = getOrCreateChannel(channelId);
501
537
  try {
502
- const result = channel.join(newId, resolvedCallsign);
538
+ const result = channel.join(newId, resolvedCallsign, { selfGenerated });
503
539
  if (!result.idempotent) {
504
540
  statsRecordJoin();
505
541
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
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",