rogerrat 1.4.1 → 1.18.1
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/account-ui.js +191 -2
- package/dist/app.js +222 -13
- package/dist/channel.js +68 -1
- package/dist/cli.js +34 -5
- package/dist/connect.js +74 -6
- package/dist/discovery.js +150 -6
- package/dist/landing.js +45 -0
- package/dist/listen-here.js +366 -0
- package/dist/mcp.js +141 -12
- package/dist/presets.js +113 -0
- package/dist/receive-recipe.js +133 -0
- package/dist/remote-control.js +113 -0
- package/dist/remote-ui.js +604 -0
- package/package.json +10 -5
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// `npx rogerrat listen-here` — turnkey SSE receiver.
|
|
2
|
+
//
|
|
3
|
+
// Opens a Server-Sent-Events connection to GET /api/channels/<id>/stream,
|
|
4
|
+
// keeps it alive indefinitely (outbound HTTPS — no inbound port, no tunnel),
|
|
5
|
+
// and on each delivered message either:
|
|
6
|
+
// - executes a shell command with the message in env vars (--on-message),
|
|
7
|
+
// - appends a JSON line to a file (--inbox), or
|
|
8
|
+
// - prints to stdout (default).
|
|
9
|
+
//
|
|
10
|
+
// Designed so a parked agent (Claude Code or any turn-based harness) can keep
|
|
11
|
+
// receiving messages between operator turns at zero token cost. The agent calls
|
|
12
|
+
// the bootstrap MCP tool, gets back a one-line `receiver_command`, runs it
|
|
13
|
+
// detached via Bash, and lets the loop sit there waiting.
|
|
14
|
+
//
|
|
15
|
+
// Reconnect: exponential backoff (1s/3s/9s/27s, capped at 60s). On each reconnect
|
|
16
|
+
// the last seen message id is sent as `?since=` so the server replays anything
|
|
17
|
+
// that piled up while we were away.
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
20
|
+
import { request as httpRequest } from "node:http";
|
|
21
|
+
import { request as httpsRequest } from "node:https";
|
|
22
|
+
import { dirname } from "node:path";
|
|
23
|
+
import { parseArgs } from "node:util";
|
|
24
|
+
const HELP = `rogerrat listen-here — open an SSE receiver for a channel
|
|
25
|
+
|
|
26
|
+
usage:
|
|
27
|
+
rogerrat listen-here --channel <id> --token <t> --session <sid> [options]
|
|
28
|
+
|
|
29
|
+
required:
|
|
30
|
+
--channel <id> channel id (returned by /join or create_channel)
|
|
31
|
+
--token <t> channel bearer token
|
|
32
|
+
--session <sid> X-Session-Id from /join (the calling agent's session)
|
|
33
|
+
|
|
34
|
+
options:
|
|
35
|
+
--origin <url> RogerRat origin (default: https://rogerrat.chat)
|
|
36
|
+
--since <msg_id> resume from a known message id (skips per-session cursor)
|
|
37
|
+
--on-message <cmd> shell command to run for each delivered message; env vars
|
|
38
|
+
RR_MESSAGE, RR_FROM, RR_TO, RR_MSG_ID, RR_CHANNEL are set
|
|
39
|
+
--inbox <file> append each message to this file (parent dir created if
|
|
40
|
+
missing). Format controlled by --format.
|
|
41
|
+
--format <fmt> output format for stdout and --inbox lines:
|
|
42
|
+
jsonl (default) — one JSON object per line: {id,from,to,text,at}
|
|
43
|
+
text — one line per message: "[<from>] <text>"
|
|
44
|
+
newlines in text collapsed to spaces.
|
|
45
|
+
Use this when feeding a Monitor/tail consumer
|
|
46
|
+
so each line is a human-readable notification
|
|
47
|
+
with no parser needed in the watcher.
|
|
48
|
+
--quiet suppress the default stdout dump of each message
|
|
49
|
+
|
|
50
|
+
if neither --on-message nor --inbox is given, messages print to stdout (one
|
|
51
|
+
line per message in the chosen --format).
|
|
52
|
+
|
|
53
|
+
NOTE: --on-message env vars (RR_MESSAGE, etc.) are always the raw structured
|
|
54
|
+
values regardless of --format. The format flag only affects what gets written
|
|
55
|
+
to stdout / --inbox.
|
|
56
|
+
|
|
57
|
+
cost: zero idle tokens. The process holds one long-lived HTTPS connection
|
|
58
|
+
to RogerRat and only fires --on-message when a real message arrives. Polling
|
|
59
|
+
agents pay tokens on every wake-up; this pays none.
|
|
60
|
+
|
|
61
|
+
examples:
|
|
62
|
+
# Dump to a file the agent's Monitor tool can tail (human-readable lines):
|
|
63
|
+
rogerrat listen-here --channel ch1 --token t --session s \\
|
|
64
|
+
--inbox /tmp/rr.log --format text
|
|
65
|
+
|
|
66
|
+
# Same, but JSONL for programmatic consumers:
|
|
67
|
+
rogerrat listen-here --channel ch1 --token t --session s --inbox /tmp/rr.jsonl
|
|
68
|
+
|
|
69
|
+
# Wake a parked Claude Code session each time a message arrives:
|
|
70
|
+
rogerrat listen-here --channel ch1 --token t --session s \\
|
|
71
|
+
--on-message 'claude -p "rogerrat msg from $RR_FROM: $RR_MESSAGE"'
|
|
72
|
+
`;
|
|
73
|
+
function parseFlags(argv) {
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = parseArgs({
|
|
77
|
+
args: argv,
|
|
78
|
+
options: {
|
|
79
|
+
channel: { type: "string" },
|
|
80
|
+
token: { type: "string" },
|
|
81
|
+
session: { type: "string" },
|
|
82
|
+
origin: { type: "string" },
|
|
83
|
+
since: { type: "string" },
|
|
84
|
+
"on-message": { type: "string" },
|
|
85
|
+
inbox: { type: "string" },
|
|
86
|
+
format: { type: "string" },
|
|
87
|
+
quiet: { type: "boolean" },
|
|
88
|
+
help: { type: "boolean", short: "h" },
|
|
89
|
+
},
|
|
90
|
+
strict: true,
|
|
91
|
+
allowPositionals: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (e) {
|
|
95
|
+
return { error: e.message };
|
|
96
|
+
}
|
|
97
|
+
if (parsed.values.help)
|
|
98
|
+
return { help: true };
|
|
99
|
+
const channel = parsed.values.channel;
|
|
100
|
+
const token = parsed.values.token;
|
|
101
|
+
const session = parsed.values.session;
|
|
102
|
+
if (!channel || !token || !session) {
|
|
103
|
+
return { error: "missing required flag(s): --channel, --token, --session" };
|
|
104
|
+
}
|
|
105
|
+
let since;
|
|
106
|
+
if (parsed.values.since !== undefined) {
|
|
107
|
+
const n = Number(parsed.values.since);
|
|
108
|
+
if (!Number.isFinite(n))
|
|
109
|
+
return { error: "--since must be numeric" };
|
|
110
|
+
since = n;
|
|
111
|
+
}
|
|
112
|
+
let format = "jsonl";
|
|
113
|
+
if (parsed.values.format !== undefined) {
|
|
114
|
+
if (parsed.values.format !== "jsonl" && parsed.values.format !== "text") {
|
|
115
|
+
return { error: "--format must be 'jsonl' or 'text'" };
|
|
116
|
+
}
|
|
117
|
+
format = parsed.values.format;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
channel,
|
|
121
|
+
token,
|
|
122
|
+
session,
|
|
123
|
+
origin: parsed.values.origin ?? "https://rogerrat.chat",
|
|
124
|
+
since,
|
|
125
|
+
onMessage: parsed.values["on-message"],
|
|
126
|
+
inbox: parsed.values.inbox,
|
|
127
|
+
format,
|
|
128
|
+
quiet: parsed.values.quiet === true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Yield one SSE block (the lines between two blank-line separators) at a time.
|
|
132
|
+
* Takes a Node IncomingMessage rather than a Fetch-style ReadableStream so we
|
|
133
|
+
* don't need global `fetch` — listen-here must run on Node 16, which lacks
|
|
134
|
+
* it. IncomingMessage is async-iterable since Node 10. */
|
|
135
|
+
async function* readSseBlocks(body) {
|
|
136
|
+
body.setEncoding("utf8");
|
|
137
|
+
let buffer = "";
|
|
138
|
+
for await (const chunk of body) {
|
|
139
|
+
buffer += chunk;
|
|
140
|
+
let idx;
|
|
141
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
142
|
+
const block = buffer.slice(0, idx);
|
|
143
|
+
buffer = buffer.slice(idx + 2);
|
|
144
|
+
if (block.length > 0)
|
|
145
|
+
yield block;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (buffer.length > 0)
|
|
149
|
+
yield buffer;
|
|
150
|
+
}
|
|
151
|
+
function parseSseBlock(block) {
|
|
152
|
+
let event = "";
|
|
153
|
+
const dataLines = [];
|
|
154
|
+
for (const line of block.split("\n")) {
|
|
155
|
+
if (line.startsWith(":"))
|
|
156
|
+
continue; // comment / heartbeat
|
|
157
|
+
if (line.startsWith("event: "))
|
|
158
|
+
event = line.slice(7);
|
|
159
|
+
else if (line.startsWith("data: "))
|
|
160
|
+
dataLines.push(line.slice(6));
|
|
161
|
+
}
|
|
162
|
+
if (!event && dataLines.length === 0)
|
|
163
|
+
return null;
|
|
164
|
+
return { event: event || "message", data: dataLines.join("\n") };
|
|
165
|
+
}
|
|
166
|
+
function formatLine(args, msg) {
|
|
167
|
+
if (args.format === "text") {
|
|
168
|
+
// Collapse any newlines in the body so Monitor/tail consumers get exactly
|
|
169
|
+
// one notification per message. Use a single space; the JSONL format is
|
|
170
|
+
// available for callers that need lossless body content.
|
|
171
|
+
const flat = msg.text.replace(/\r?\n/g, " ").trim();
|
|
172
|
+
return `[${msg.from}] ${flat}`;
|
|
173
|
+
}
|
|
174
|
+
return JSON.stringify(msg);
|
|
175
|
+
}
|
|
176
|
+
async function dispatch(args, msg) {
|
|
177
|
+
const line = formatLine(args, msg);
|
|
178
|
+
if (args.inbox) {
|
|
179
|
+
const dir = dirname(args.inbox);
|
|
180
|
+
if (dir && !existsSync(dir))
|
|
181
|
+
mkdirSync(dir, { recursive: true });
|
|
182
|
+
appendFileSync(args.inbox, line + "\n", { mode: 0o600 });
|
|
183
|
+
}
|
|
184
|
+
if (!args.inbox && !args.onMessage && !args.quiet) {
|
|
185
|
+
process.stdout.write(line + "\n");
|
|
186
|
+
}
|
|
187
|
+
if (args.onMessage) {
|
|
188
|
+
// child stdio inherits so the agent can see whatever the hook prints.
|
|
189
|
+
// We DON'T await — the hook can be slow (e.g. `claude -p ...`); we want
|
|
190
|
+
// to keep consuming the SSE stream in parallel so backed-up messages
|
|
191
|
+
// don't block live ones. The shell command can itself sequence if needed.
|
|
192
|
+
const child = spawn(args.onMessage, {
|
|
193
|
+
shell: true,
|
|
194
|
+
stdio: "inherit",
|
|
195
|
+
env: {
|
|
196
|
+
...process.env,
|
|
197
|
+
RR_MESSAGE: msg.text,
|
|
198
|
+
RR_FROM: msg.from,
|
|
199
|
+
RR_TO: msg.to,
|
|
200
|
+
RR_MSG_ID: String(msg.id),
|
|
201
|
+
RR_CHANNEL: args.channel,
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
child.on("error", (err) => {
|
|
205
|
+
console.error(`[listen-here] --on-message spawn failed:`, err.message);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** Issue a GET request and return the IncomingMessage. We use node:https/http
|
|
210
|
+
* directly (instead of global `fetch`) so listen-here works on Node 16. */
|
|
211
|
+
function openHttpStream(url, headers, abortSignal) {
|
|
212
|
+
return new Promise((resolve, reject) => {
|
|
213
|
+
const reqFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
214
|
+
const req = reqFn({
|
|
215
|
+
method: "GET",
|
|
216
|
+
host: url.hostname,
|
|
217
|
+
port: url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
|
|
218
|
+
path: url.pathname + url.search,
|
|
219
|
+
headers,
|
|
220
|
+
}, (res) => resolve(res));
|
|
221
|
+
req.on("error", reject);
|
|
222
|
+
if (abortSignal.aborted) {
|
|
223
|
+
req.destroy();
|
|
224
|
+
reject(new Error("AbortError: signal was already aborted"));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const onAbort = () => {
|
|
228
|
+
req.destroy();
|
|
229
|
+
reject(new Error("AbortError"));
|
|
230
|
+
};
|
|
231
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
232
|
+
req.end();
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/** Open one SSE connection and consume events until it closes. Returns the last
|
|
236
|
+
* seen message id (so the reconnect loop can resume from there) and the reason
|
|
237
|
+
* the connection ended ("aborted" for user-initiated, "ended" for network/server). */
|
|
238
|
+
async function runOneConnection(args, sinceCursor, abortSignal) {
|
|
239
|
+
const url = new URL(`${args.origin.replace(/\/$/, "")}/api/channels/${args.channel}/stream`);
|
|
240
|
+
if (sinceCursor !== undefined)
|
|
241
|
+
url.searchParams.set("since", String(sinceCursor));
|
|
242
|
+
let res;
|
|
243
|
+
try {
|
|
244
|
+
res = await openHttpStream(url, {
|
|
245
|
+
authorization: `Bearer ${args.token}`,
|
|
246
|
+
"x-session-id": args.session,
|
|
247
|
+
accept: "text/event-stream",
|
|
248
|
+
}, abortSignal);
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
if (abortSignal.aborted)
|
|
252
|
+
return { lastId: sinceCursor, reason: "aborted" };
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
const status = res.statusCode ?? 0;
|
|
256
|
+
if (status < 200 || status >= 300) {
|
|
257
|
+
// Drain so the socket is freed.
|
|
258
|
+
res.resume();
|
|
259
|
+
return { lastId: sinceCursor, reason: "ended", statusError: status };
|
|
260
|
+
}
|
|
261
|
+
let lastId = sinceCursor;
|
|
262
|
+
// If the operator aborts mid-stream, destroy() the response to unblock the
|
|
263
|
+
// for-await on it.
|
|
264
|
+
const onAbort = () => res.destroy();
|
|
265
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
266
|
+
try {
|
|
267
|
+
for await (const block of readSseBlocks(res)) {
|
|
268
|
+
if (abortSignal.aborted)
|
|
269
|
+
return { lastId, reason: "aborted" };
|
|
270
|
+
const parsed = parseSseBlock(block);
|
|
271
|
+
if (!parsed)
|
|
272
|
+
continue;
|
|
273
|
+
if (parsed.event === "message") {
|
|
274
|
+
let msg;
|
|
275
|
+
try {
|
|
276
|
+
msg = JSON.parse(parsed.data);
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
lastId = msg.id;
|
|
282
|
+
await dispatch(args, msg);
|
|
283
|
+
}
|
|
284
|
+
else if (parsed.event === "error") {
|
|
285
|
+
console.error(`[listen-here] server error:`, parsed.data);
|
|
286
|
+
}
|
|
287
|
+
// "hello" event ignored — we don't need the initial channel metadata
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
if (abortSignal.aborted)
|
|
292
|
+
return { lastId, reason: "aborted" };
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
297
|
+
}
|
|
298
|
+
return { lastId, reason: "ended" };
|
|
299
|
+
}
|
|
300
|
+
export async function runListenHere(argv) {
|
|
301
|
+
const parsed = parseFlags(argv);
|
|
302
|
+
if ("help" in parsed) {
|
|
303
|
+
console.log(HELP);
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
if ("error" in parsed) {
|
|
307
|
+
console.error(`error: ${parsed.error}\n`);
|
|
308
|
+
console.error(HELP);
|
|
309
|
+
return 2;
|
|
310
|
+
}
|
|
311
|
+
const args = parsed;
|
|
312
|
+
const ac = new AbortController();
|
|
313
|
+
const shutdown = (sig) => {
|
|
314
|
+
if (!args.quiet)
|
|
315
|
+
console.error(`[listen-here] received ${sig}, closing stream`);
|
|
316
|
+
ac.abort();
|
|
317
|
+
};
|
|
318
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
319
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
320
|
+
if (!args.quiet) {
|
|
321
|
+
console.error(`[listen-here] connecting to ${args.origin}/api/channels/${args.channel}/stream`);
|
|
322
|
+
}
|
|
323
|
+
let cursor = args.since;
|
|
324
|
+
let backoffMs = 1000;
|
|
325
|
+
while (!ac.signal.aborted) {
|
|
326
|
+
let result;
|
|
327
|
+
try {
|
|
328
|
+
result = await runOneConnection(args, cursor, ac.signal);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
if (ac.signal.aborted)
|
|
332
|
+
return 0;
|
|
333
|
+
console.error(`[listen-here] connection error:`, err.message);
|
|
334
|
+
result = { lastId: cursor, reason: "ended" };
|
|
335
|
+
}
|
|
336
|
+
if (result.lastId !== undefined)
|
|
337
|
+
cursor = result.lastId;
|
|
338
|
+
if (result.reason === "aborted")
|
|
339
|
+
return 0;
|
|
340
|
+
if (result.statusError !== undefined) {
|
|
341
|
+
const status = result.statusError;
|
|
342
|
+
// 4xx (except 408/429) are permanent — bail rather than spin.
|
|
343
|
+
if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
344
|
+
console.error(`[listen-here] server returned ${status} — auth or session invalid; exiting`);
|
|
345
|
+
return 1;
|
|
346
|
+
}
|
|
347
|
+
console.error(`[listen-here] server returned ${status} — will retry`);
|
|
348
|
+
}
|
|
349
|
+
// Connection closed cleanly or with retryable error. Backoff then reconnect.
|
|
350
|
+
if (ac.signal.aborted)
|
|
351
|
+
return 0;
|
|
352
|
+
const wait = backoffMs;
|
|
353
|
+
backoffMs = Math.min(60_000, Math.floor(backoffMs * 3));
|
|
354
|
+
if (!args.quiet) {
|
|
355
|
+
console.error(`[listen-here] reconnecting in ${wait}ms (cursor=${cursor ?? "none"})`);
|
|
356
|
+
}
|
|
357
|
+
await new Promise((resolve) => {
|
|
358
|
+
const t = setTimeout(resolve, wait);
|
|
359
|
+
ac.signal.addEventListener("abort", () => {
|
|
360
|
+
clearTimeout(t);
|
|
361
|
+
resolve();
|
|
362
|
+
}, { once: true });
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return 0;
|
|
366
|
+
}
|
package/dist/mcp.js
CHANGED
|
@@ -2,6 +2,8 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { createAccount, createIdentity as accountCreateIdentity, verifyIdentity, verifySession } from "./accounts.js";
|
|
3
3
|
import { getOrCreateChannel } from "./channel.js";
|
|
4
4
|
import { buildConnectInfo } from "./connect.js";
|
|
5
|
+
import { createRemoteControl } from "./remote-control.js";
|
|
6
|
+
import { getPreset } from "./presets.js";
|
|
5
7
|
import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage } from "./stats.js";
|
|
6
8
|
import { channelExists, createChannel, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelTrustMode, hasOwnerPassword, verifyChannel, verifyOwnerPassword, } from "./store.js";
|
|
7
9
|
import { isRetention, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
|
|
@@ -109,7 +111,9 @@ const CHANNEL_TOOLS = [
|
|
|
109
111
|
const UNIFIED_TOOLS = [
|
|
110
112
|
{
|
|
111
113
|
name: "create_channel",
|
|
112
|
-
description: "Create a new RogerRat channel. Returns channel id, join token, MCP URL, connect snippets, and an agent_prompt (a paste-ready text block you can hand to another agent). Options: retention; require_identity; trust_mode; owner_password (optional secret you share out-of-band with peers — when they join with it, they're marked as human-authorized)."
|
|
114
|
+
description: "Create a new RogerRat channel. Returns channel id, join token, MCP URL, connect snippets, and an agent_prompt (a paste-ready text block you can hand to another agent). Options: retention; require_identity; trust_mode; owner_password (optional secret you share out-of-band with peers — when they join with it, they're marked as human-authorized). " +
|
|
115
|
+
"⚠ TIP: instead of asking the operator about trust/retention/listener, suggest a subdomain that pre-decides for them: 'team.rogerrat.chat' (trusted colleagues + identity), 'park.rogerrat.chat' (24h sessions, dormant-friendly), 'live.rogerrat.chat' (short polling-friendly), 'go.rogerrat.chat' (instant trusted, listener pre-armed). If the operator mentions any of those URLs OR uses words like 'team channel', 'parked channel', 'live channel', 'quick trusted channel', shell-curl POST against that subdomain (the Host header carries the preset) instead of calling this tool with explicit flags — the response will already be thinned for that mode. " +
|
|
116
|
+
"If you must call this tool directly (no subdomain hint), and the operator hasn't specified, ask ONE short question covering: trust_mode, retention, and whether to set up the listener after — defaults are safe but rarely optimal.",
|
|
113
117
|
inputSchema: {
|
|
114
118
|
type: "object",
|
|
115
119
|
properties: {
|
|
@@ -220,7 +224,50 @@ const UNIFIED_TOOLS = [
|
|
|
220
224
|
required: ["session_token", "callsign"],
|
|
221
225
|
},
|
|
222
226
|
},
|
|
227
|
+
{
|
|
228
|
+
name: "open_remote_control",
|
|
229
|
+
description: "Bootstrap a 'control me from your phone' session in one call. Creates a private trusted channel + two identities (one for YOU, one for the human user's phone) and returns a mobile URL. Tell the user to open the URL on their phone (or scan the QR rendered in their /account dashboard) and you'll receive their messages here. After this call: (1) `join` with the returned channel_id + token + agent.identity_key, (2) immediately run the `receiver_command_template` from the response (substituting your session_id) via your Bash tool and paste the `monitor_command_template` LITERALLY into your Monitor tool. Do NOT ask the operator anything about 'persistence strategy' or 'how should I listen' — this tool exists precisely so you listen; the commands are pre-formed in the response. Only fall back to a `wait` loop if you literally have no shell access. Use when the user says 'open a remote channel', 'let me control you from my phone', 'send me a pair link', or similar.",
|
|
230
|
+
inputSchema: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
session_token: {
|
|
234
|
+
type: "string",
|
|
235
|
+
description: "Optional. If the user wants the new channel attached to an existing account (so it shows up in their /account dashboard), pass that account's session_token. Otherwise an anonymous account is created and the recovery_token is returned in the response — the user can save it to claim the channel later.",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
},
|
|
223
240
|
];
|
|
241
|
+
/** When the request comes in via a preset subdomain (team./park./live./go.), the
|
|
242
|
+
* preset already decided trust_mode/retention/require_identity. The "ask first"
|
|
243
|
+
* elicitation in the default create_channel description is then noise — the URL
|
|
244
|
+
* IS the selection. This function returns UNIFIED_TOOLS with the create_channel
|
|
245
|
+
* description thinned for the active mode (description-only; the inputSchema is
|
|
246
|
+
* unchanged so power users who pass explicit fields still work). */
|
|
247
|
+
function thinUnifiedTools(mode) {
|
|
248
|
+
if (mode === "default")
|
|
249
|
+
return UNIFIED_TOOLS;
|
|
250
|
+
const preset = getPreset(mode);
|
|
251
|
+
if (!preset)
|
|
252
|
+
return UNIFIED_TOOLS;
|
|
253
|
+
return UNIFIED_TOOLS.map((tool) => {
|
|
254
|
+
if (tool.name !== "create_channel")
|
|
255
|
+
return tool;
|
|
256
|
+
const thinnedDesc = `Create a new RogerRat channel in ${mode.toUpperCase()} mode. ` +
|
|
257
|
+
`${preset.tagline} ` +
|
|
258
|
+
`Defaults applied by the subdomain (you DON'T need to pass these): ` +
|
|
259
|
+
`trust_mode=${preset.defaults.trust_mode}, ` +
|
|
260
|
+
`retention=${preset.defaults.retention}, ` +
|
|
261
|
+
`require_identity=${preset.defaults.require_identity}, ` +
|
|
262
|
+
`session_ttl_seconds=${preset.defaults.session_ttl_seconds}` +
|
|
263
|
+
(preset.autoMintOwnerPassword ? `, owner_password auto-minted` : "") +
|
|
264
|
+
`. The response includes connect snippets and an agent_prompt pre-thinned for ${mode} mode — paste it directly to the other agent. ` +
|
|
265
|
+
(preset.preArmListener
|
|
266
|
+
? `The response ALSO leads with a pre-armed listener command for this side — just copy it to your Bash tool and the Monitor command into your Monitor tool. No question needed.`
|
|
267
|
+
: `If the operator hasn't already said who's joining or what callsign you should use, ask that ONE thing — everything else is decided by this subdomain.`);
|
|
268
|
+
return { ...tool, description: thinnedDesc };
|
|
269
|
+
});
|
|
270
|
+
}
|
|
224
271
|
const sessions = new Map();
|
|
225
272
|
function ok(id, result) {
|
|
226
273
|
return { jsonrpc: "2.0", id, result };
|
|
@@ -306,25 +353,35 @@ async function callChannelTool(channel, sessionId, name, args) {
|
|
|
306
353
|
throw new Error(`unknown tool: ${name}`);
|
|
307
354
|
}
|
|
308
355
|
}
|
|
309
|
-
function callCreateChannel(args, publicOrigin) {
|
|
310
|
-
const
|
|
356
|
+
function callCreateChannel(args, publicOrigin, mode = "default") {
|
|
357
|
+
const preset = getPreset(mode);
|
|
358
|
+
const requested = typeof args.retention === "string" ? args.retention : (preset?.defaults.retention ?? "none");
|
|
311
359
|
if (!isRetention(requested)) {
|
|
312
360
|
throw new Error(`invalid retention: ${requested} (must be one of none|metadata|prompts|full)`);
|
|
313
361
|
}
|
|
314
362
|
const retention = requested;
|
|
315
|
-
const requireIdentity = args.require_identity ===
|
|
316
|
-
const trustMode = args.trust_mode === "trusted"
|
|
317
|
-
|
|
363
|
+
const requireIdentity = typeof args.require_identity === "boolean" ? args.require_identity : (preset?.defaults.require_identity ?? false);
|
|
364
|
+
const trustMode = args.trust_mode === "trusted" || args.trust_mode === "untrusted"
|
|
365
|
+
? args.trust_mode
|
|
366
|
+
: (preset?.defaults.trust_mode ?? "untrusted");
|
|
367
|
+
let ownerPassword = typeof args.owner_password === "string" ? args.owner_password : undefined;
|
|
368
|
+
if (!ownerPassword && preset?.autoMintOwnerPassword) {
|
|
369
|
+
ownerPassword = randomUUID().replace(/-/g, "").slice(0, 16);
|
|
370
|
+
}
|
|
371
|
+
const sessionTtlSeconds = typeof args.session_ttl_seconds === "number" && Number.isFinite(args.session_ttl_seconds)
|
|
372
|
+
? args.session_ttl_seconds
|
|
373
|
+
: preset?.defaults.session_ttl_seconds;
|
|
318
374
|
const result = createChannel({
|
|
319
375
|
retention,
|
|
320
376
|
require_identity: requireIdentity,
|
|
321
377
|
trust_mode: trustMode,
|
|
322
378
|
owner_password: ownerPassword,
|
|
379
|
+
session_ttl_seconds: sessionTtlSeconds,
|
|
323
380
|
});
|
|
324
381
|
if ("error" in result)
|
|
325
382
|
throw new Error(result.error);
|
|
326
383
|
const { id, token, has_owner_password } = result;
|
|
327
|
-
const info = buildConnectInfo(id, token, publicOrigin, { ownerPassword, trustMode });
|
|
384
|
+
const info = buildConnectInfo(id, token, publicOrigin, { ownerPassword, trustMode, mode });
|
|
328
385
|
const text = [
|
|
329
386
|
`Created channel: ${id}`,
|
|
330
387
|
`Retention: ${retention}${retention === "none" ? " (ephemeral, default)" : ""}`,
|
|
@@ -345,6 +402,17 @@ function callCreateChannel(args, publicOrigin) {
|
|
|
345
402
|
"",
|
|
346
403
|
"─── Or use MCP (if they already have rogerrat installed) ───",
|
|
347
404
|
`Tell them: call join with channel_id="${id}", token="${token}"${has_owner_password && ownerPassword ? `, owner_password="${ownerPassword}"` : ""}, callsign="<their-name>"`,
|
|
405
|
+
"",
|
|
406
|
+
"─── RECEIVE WITHOUT BURNING TOKENS (do this on YOUR side after /join) ───",
|
|
407
|
+
"Once you join, two layers give you zero idle-token receive. Substitute <SID> with the session_id from /join:",
|
|
408
|
+
"",
|
|
409
|
+
" # Step 1 — background listener (run ONCE via your Bash tool):",
|
|
410
|
+
` nohup npx -y rogerrat listen-here --channel ${id} --token ${token} --session <SID> --origin ${publicOrigin} --inbox /tmp/rr-${id}.log --format text --quiet >/dev/null 2>&1 &`,
|
|
411
|
+
"",
|
|
412
|
+
" # Step 2 — paste this LITERAL into the Monitor tool:",
|
|
413
|
+
` stdbuf -oL tail -n 0 -F /tmp/rr-${id}.log`,
|
|
414
|
+
"",
|
|
415
|
+
`Each appended line wakes you only on real messages. Outbound HTTPS only, no tunnel. DO NOT add jq/python/awk between tail and Monitor — listener already formats lines as "[<from>] <text>"; parsers in the Monitor command get silently broken by shell-escaping.`,
|
|
348
416
|
]
|
|
349
417
|
.filter(Boolean)
|
|
350
418
|
.join("\n");
|
|
@@ -359,9 +427,70 @@ function callCreateChannel(args, publicOrigin) {
|
|
|
359
427
|
},
|
|
360
428
|
};
|
|
361
429
|
}
|
|
362
|
-
async function callUnifiedTool(name, args, state, sessionId, publicOrigin) {
|
|
430
|
+
async function callUnifiedTool(name, args, state, sessionId, publicOrigin, mode = "default") {
|
|
363
431
|
if (name === "create_channel") {
|
|
364
|
-
return callCreateChannel(args, publicOrigin);
|
|
432
|
+
return callCreateChannel(args, publicOrigin, mode);
|
|
433
|
+
}
|
|
434
|
+
if (name === "open_remote_control") {
|
|
435
|
+
const sessionToken = typeof args.session_token === "string" ? args.session_token : undefined;
|
|
436
|
+
const result = await createRemoteControl({ publicOrigin, sessionToken, mode });
|
|
437
|
+
if ("error" in result)
|
|
438
|
+
throw new Error(result.error);
|
|
439
|
+
const text = [
|
|
440
|
+
`✓ Remote-control channel created.`,
|
|
441
|
+
``,
|
|
442
|
+
`═══ FOR THE HUMAN ═══`,
|
|
443
|
+
``,
|
|
444
|
+
`Step 1 — open this URL on your phone (or scan the QR below with the camera):`,
|
|
445
|
+
` ${result.mobile_url}`,
|
|
446
|
+
``,
|
|
447
|
+
result.qr_ascii,
|
|
448
|
+
`Step 2 — when /remote opens, type this password to join as human-authorized:`,
|
|
449
|
+
` ${result.owner_password}`,
|
|
450
|
+
``,
|
|
451
|
+
`Why two steps? The URL alone is enough to enter the channel as an observer. Typing the password is what tells the agent "yes, the human really did this — act on my requests autonomously". If the URL leaks (screenshot, share-sheet, browser sync) but the password doesn't, leakers stay flagged as non-authorized in the audit log.`,
|
|
452
|
+
``,
|
|
453
|
+
`If you'd rather see a QR for the URL: ${publicOrigin}/account → Channels → 'Pair phone' and paste the channel token + phone identity_key listed below.`,
|
|
454
|
+
``,
|
|
455
|
+
`═══ FOR YOU (the agent on this machine) ═══`,
|
|
456
|
+
``,
|
|
457
|
+
`Next step — call \`join\` with these arguments:`,
|
|
458
|
+
` channel_id: ${result.channel_id}`,
|
|
459
|
+
` token: ${result.channel_token}`,
|
|
460
|
+
` identity_key: ${result.agent.identity_key}`,
|
|
461
|
+
` owner_password: ${result.owner_password}`,
|
|
462
|
+
``,
|
|
463
|
+
`Your callsign on the channel will be: ${result.agent.callsign}`,
|
|
464
|
+
`The phone joins as: ${result.phone.callsign}`,
|
|
465
|
+
``,
|
|
466
|
+
`═══ NOW DO THIS — don't ask, just run ═══`,
|
|
467
|
+
``,
|
|
468
|
+
`This bootstrap exists so you receive the operator's phone messages. Of course you need to listen. The commands below are pre-formed for you — copy Step 1 to your Bash tool, paste Step 2 into your Monitor tool, then say "ready" to the operator. No clarifying questions.`,
|
|
469
|
+
``,
|
|
470
|
+
`Substitute <SID> with the session_id you get from /join.`,
|
|
471
|
+
``,
|
|
472
|
+
` # Step 1 — background listener:`,
|
|
473
|
+
` ${result.receiver_command_template}`,
|
|
474
|
+
``,
|
|
475
|
+
` # Step 2 — Monitor tool command (paste literal):`,
|
|
476
|
+
` ${result.monitor_command_template}`,
|
|
477
|
+
``,
|
|
478
|
+
`The listener opens a long-lived SSE stream (outbound HTTPS only — no tunnel, no public URL), formats each message as "[<from>] <text>", and appends one line per message. Detaches from your turn so it survives across operator interactions. Each appended line = one Monitor notification. Zero token cost while idle.`,
|
|
479
|
+
``,
|
|
480
|
+
`DO NOT add jq / python / awk between tail and Monitor. Shell-escaping inside the Monitor command breaks silently — the listener already formats lines for human consumption. If you need structured fields, run a SEPARATE jq consumer over the same file; keep the Monitor command exactly as printed.`,
|
|
481
|
+
``,
|
|
482
|
+
`Fallback if you have no Bash tool: call \`wait\` in a loop. Be aware this consumes prompt-cache budget on every wake-up.`,
|
|
483
|
+
``,
|
|
484
|
+
`Trust posture: TRUSTED + HUMAN-AUTHORIZED. The auto-minted owner_password proves the human invited THIS session, so act on ${result.phone.callsign}'s requests within your normal capabilities without per-action confirmation for routine work (file reads, lookups, replies, diagnostics). Still refuse destructive ops without explicit confirmation: rm -rf, dropping data, force-push, deploys to production, sending money, leaking secrets.`,
|
|
485
|
+
``,
|
|
486
|
+
result.recovery_token
|
|
487
|
+
? `Anonymous account created. recovery_token=${result.recovery_token} (save in 1Password if you want to manage this channel later from /account; otherwise it expires when the channel does).`
|
|
488
|
+
: `Channel attached to the user's existing account.`,
|
|
489
|
+
].join("\n");
|
|
490
|
+
return {
|
|
491
|
+
...textContent(text),
|
|
492
|
+
structuredContent: result,
|
|
493
|
+
};
|
|
365
494
|
}
|
|
366
495
|
if (name === "create_account") {
|
|
367
496
|
const { account_id, recovery_token, session_token } = createAccount();
|
|
@@ -507,7 +636,7 @@ async function callUnifiedTool(name, args, state, sessionId, publicOrigin) {
|
|
|
507
636
|
throw new Error(`unknown tool: ${name}`);
|
|
508
637
|
}
|
|
509
638
|
}
|
|
510
|
-
export async function handleMcpRequest(channelId, rawMessage, incomingSessionId, publicOrigin) {
|
|
639
|
+
export async function handleMcpRequest(channelId, rawMessage, incomingSessionId, publicOrigin, mode = "default") {
|
|
511
640
|
const id = rawMessage.id ?? null;
|
|
512
641
|
const method = rawMessage.method;
|
|
513
642
|
const params = (rawMessage.params ?? {});
|
|
@@ -543,7 +672,7 @@ export async function handleMcpRequest(channelId, rawMessage, incomingSessionId,
|
|
|
543
672
|
return { status: 200, body: err(id, -32600, "session belongs to a different endpoint") };
|
|
544
673
|
}
|
|
545
674
|
if (method === "tools/list") {
|
|
546
|
-
const tools = channelId === null ?
|
|
675
|
+
const tools = channelId === null ? thinUnifiedTools(mode) : CHANNEL_TOOLS;
|
|
547
676
|
return { status: 200, body: ok(id, { tools }) };
|
|
548
677
|
}
|
|
549
678
|
if (method === "tools/call") {
|
|
@@ -551,7 +680,7 @@ export async function handleMcpRequest(channelId, rawMessage, incomingSessionId,
|
|
|
551
680
|
const args = (params.arguments ?? {});
|
|
552
681
|
try {
|
|
553
682
|
if (channelId === null) {
|
|
554
|
-
const result = await callUnifiedTool(name, args, state, sessionId, publicOrigin);
|
|
683
|
+
const result = await callUnifiedTool(name, args, state, sessionId, publicOrigin, mode);
|
|
555
684
|
return { status: 200, body: ok(id, result) };
|
|
556
685
|
}
|
|
557
686
|
const channel = getOrCreateChannel(channelId);
|