rogerrat 1.4.1 → 1.19.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/account-ui.js +191 -2
- package/dist/app.js +237 -16
- package/dist/channel.js +83 -2
- 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 +402 -0
- package/dist/mcp.js +162 -19
- 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,402 @@
|
|
|
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,
|
|
39
|
+
RR_PRIORITY are set (RR_PRIORITY = "default" when omitted)
|
|
40
|
+
--inbox <file> append each message to this file (parent dir created if
|
|
41
|
+
missing). Format controlled by --format.
|
|
42
|
+
--format <fmt> output format for stdout and --inbox lines:
|
|
43
|
+
jsonl (default) — one JSON object per line: {id,from,to,text,at,priority?}
|
|
44
|
+
text — one line per message:
|
|
45
|
+
"[<priority>] [<from>] <text>" if priority set,
|
|
46
|
+
"[<from>] <text>" otherwise.
|
|
47
|
+
newlines in text collapsed to spaces.
|
|
48
|
+
Use this when feeding a Monitor/tail consumer
|
|
49
|
+
so each line is a human-readable notification
|
|
50
|
+
with no parser needed in the watcher.
|
|
51
|
+
--min-priority <p> drop messages below this priority (min|low|default|high|urgent).
|
|
52
|
+
Filtered messages do NOT write to --inbox, fire --on-message,
|
|
53
|
+
or print to stdout. Use for park-style channels: stay
|
|
54
|
+
wake-able only on real signals, let chatter accumulate silently.
|
|
55
|
+
--quiet suppress the default stdout dump of each message
|
|
56
|
+
|
|
57
|
+
if neither --on-message nor --inbox is given, messages print to stdout (one
|
|
58
|
+
line per message in the chosen --format).
|
|
59
|
+
|
|
60
|
+
NOTE: --on-message env vars (RR_MESSAGE, etc.) are always the raw structured
|
|
61
|
+
values regardless of --format. The format flag only affects what gets written
|
|
62
|
+
to stdout / --inbox.
|
|
63
|
+
|
|
64
|
+
cost: zero idle tokens. The process holds one long-lived HTTPS connection
|
|
65
|
+
to RogerRat and only fires --on-message when a real message arrives. Polling
|
|
66
|
+
agents pay tokens on every wake-up; this pays none.
|
|
67
|
+
|
|
68
|
+
examples:
|
|
69
|
+
# Dump to a file the agent's Monitor tool can tail (human-readable lines):
|
|
70
|
+
rogerrat listen-here --channel ch1 --token t --session s \\
|
|
71
|
+
--inbox /tmp/rr.log --format text
|
|
72
|
+
|
|
73
|
+
# Same, but JSONL for programmatic consumers:
|
|
74
|
+
rogerrat listen-here --channel ch1 --token t --session s --inbox /tmp/rr.jsonl
|
|
75
|
+
|
|
76
|
+
# Wake a parked Claude Code session each time a message arrives:
|
|
77
|
+
rogerrat listen-here --channel ch1 --token t --session s \\
|
|
78
|
+
--on-message 'claude -p "rogerrat msg from $RR_FROM: $RR_MESSAGE"'
|
|
79
|
+
`;
|
|
80
|
+
const PRIORITY_RANK = {
|
|
81
|
+
min: 0,
|
|
82
|
+
low: 1,
|
|
83
|
+
default: 2,
|
|
84
|
+
high: 3,
|
|
85
|
+
urgent: 4,
|
|
86
|
+
};
|
|
87
|
+
function parseFlags(argv) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = parseArgs({
|
|
91
|
+
args: argv,
|
|
92
|
+
options: {
|
|
93
|
+
channel: { type: "string" },
|
|
94
|
+
token: { type: "string" },
|
|
95
|
+
session: { type: "string" },
|
|
96
|
+
origin: { type: "string" },
|
|
97
|
+
since: { type: "string" },
|
|
98
|
+
"on-message": { type: "string" },
|
|
99
|
+
inbox: { type: "string" },
|
|
100
|
+
format: { type: "string" },
|
|
101
|
+
"min-priority": { type: "string" },
|
|
102
|
+
quiet: { type: "boolean" },
|
|
103
|
+
help: { type: "boolean", short: "h" },
|
|
104
|
+
},
|
|
105
|
+
strict: true,
|
|
106
|
+
allowPositionals: false,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
return { error: e.message };
|
|
111
|
+
}
|
|
112
|
+
if (parsed.values.help)
|
|
113
|
+
return { help: true };
|
|
114
|
+
const channel = parsed.values.channel;
|
|
115
|
+
const token = parsed.values.token;
|
|
116
|
+
const session = parsed.values.session;
|
|
117
|
+
if (!channel || !token || !session) {
|
|
118
|
+
return { error: "missing required flag(s): --channel, --token, --session" };
|
|
119
|
+
}
|
|
120
|
+
let since;
|
|
121
|
+
if (parsed.values.since !== undefined) {
|
|
122
|
+
const n = Number(parsed.values.since);
|
|
123
|
+
if (!Number.isFinite(n))
|
|
124
|
+
return { error: "--since must be numeric" };
|
|
125
|
+
since = n;
|
|
126
|
+
}
|
|
127
|
+
let format = "jsonl";
|
|
128
|
+
if (parsed.values.format !== undefined) {
|
|
129
|
+
if (parsed.values.format !== "jsonl" && parsed.values.format !== "text") {
|
|
130
|
+
return { error: "--format must be 'jsonl' or 'text'" };
|
|
131
|
+
}
|
|
132
|
+
format = parsed.values.format;
|
|
133
|
+
}
|
|
134
|
+
let minPriority;
|
|
135
|
+
if (parsed.values["min-priority"] !== undefined) {
|
|
136
|
+
const v = parsed.values["min-priority"];
|
|
137
|
+
if (v !== "min" && v !== "low" && v !== "default" && v !== "high" && v !== "urgent") {
|
|
138
|
+
return { error: "--min-priority must be one of min|low|default|high|urgent" };
|
|
139
|
+
}
|
|
140
|
+
minPriority = v;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
channel,
|
|
144
|
+
token,
|
|
145
|
+
session,
|
|
146
|
+
origin: parsed.values.origin ?? "https://rogerrat.chat",
|
|
147
|
+
since,
|
|
148
|
+
onMessage: parsed.values["on-message"],
|
|
149
|
+
inbox: parsed.values.inbox,
|
|
150
|
+
format,
|
|
151
|
+
quiet: parsed.values.quiet === true,
|
|
152
|
+
minPriority,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** Yield one SSE block (the lines between two blank-line separators) at a time.
|
|
156
|
+
* Takes a Node IncomingMessage rather than a Fetch-style ReadableStream so we
|
|
157
|
+
* don't need global `fetch` — listen-here must run on Node 16, which lacks
|
|
158
|
+
* it. IncomingMessage is async-iterable since Node 10. */
|
|
159
|
+
async function* readSseBlocks(body) {
|
|
160
|
+
body.setEncoding("utf8");
|
|
161
|
+
let buffer = "";
|
|
162
|
+
for await (const chunk of body) {
|
|
163
|
+
buffer += chunk;
|
|
164
|
+
let idx;
|
|
165
|
+
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
|
166
|
+
const block = buffer.slice(0, idx);
|
|
167
|
+
buffer = buffer.slice(idx + 2);
|
|
168
|
+
if (block.length > 0)
|
|
169
|
+
yield block;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
if (buffer.length > 0)
|
|
173
|
+
yield buffer;
|
|
174
|
+
}
|
|
175
|
+
function parseSseBlock(block) {
|
|
176
|
+
let event = "";
|
|
177
|
+
const dataLines = [];
|
|
178
|
+
for (const line of block.split("\n")) {
|
|
179
|
+
if (line.startsWith(":"))
|
|
180
|
+
continue; // comment / heartbeat
|
|
181
|
+
if (line.startsWith("event: "))
|
|
182
|
+
event = line.slice(7);
|
|
183
|
+
else if (line.startsWith("data: "))
|
|
184
|
+
dataLines.push(line.slice(6));
|
|
185
|
+
}
|
|
186
|
+
if (!event && dataLines.length === 0)
|
|
187
|
+
return null;
|
|
188
|
+
return { event: event || "message", data: dataLines.join("\n") };
|
|
189
|
+
}
|
|
190
|
+
function formatLine(args, msg) {
|
|
191
|
+
if (args.format === "text") {
|
|
192
|
+
// Collapse any newlines in the body so Monitor/tail consumers get exactly
|
|
193
|
+
// one notification per message. Use a single space; the JSONL format is
|
|
194
|
+
// available for callers that need lossless body content.
|
|
195
|
+
const flat = msg.text.replace(/\r?\n/g, " ").trim();
|
|
196
|
+
// Surface non-default priority as a leading tag so a Monitor tail of the
|
|
197
|
+
// inbox file can grep for urgency without parsing JSON. e.g. `[urgent] `
|
|
198
|
+
const prio = msg.priority && msg.priority !== "default" ? `[${msg.priority}] ` : "";
|
|
199
|
+
return `${prio}[${msg.from}] ${flat}`;
|
|
200
|
+
}
|
|
201
|
+
return JSON.stringify(msg);
|
|
202
|
+
}
|
|
203
|
+
async function dispatch(args, msg) {
|
|
204
|
+
// --min-priority filter: drop messages below the threshold entirely (no
|
|
205
|
+
// inbox write, no hook spawn, no stdout). Missing priority counts as
|
|
206
|
+
// "default" (rank 2).
|
|
207
|
+
if (args.minPriority) {
|
|
208
|
+
const incomingRank = PRIORITY_RANK[msg.priority ?? "default"];
|
|
209
|
+
if (incomingRank < PRIORITY_RANK[args.minPriority])
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const line = formatLine(args, msg);
|
|
213
|
+
if (args.inbox) {
|
|
214
|
+
const dir = dirname(args.inbox);
|
|
215
|
+
if (dir && !existsSync(dir))
|
|
216
|
+
mkdirSync(dir, { recursive: true });
|
|
217
|
+
appendFileSync(args.inbox, line + "\n", { mode: 0o600 });
|
|
218
|
+
}
|
|
219
|
+
if (!args.inbox && !args.onMessage && !args.quiet) {
|
|
220
|
+
process.stdout.write(line + "\n");
|
|
221
|
+
}
|
|
222
|
+
if (args.onMessage) {
|
|
223
|
+
// child stdio inherits so the agent can see whatever the hook prints.
|
|
224
|
+
// We DON'T await — the hook can be slow (e.g. `claude -p ...`); we want
|
|
225
|
+
// to keep consuming the SSE stream in parallel so backed-up messages
|
|
226
|
+
// don't block live ones. The shell command can itself sequence if needed.
|
|
227
|
+
const child = spawn(args.onMessage, {
|
|
228
|
+
shell: true,
|
|
229
|
+
stdio: "inherit",
|
|
230
|
+
env: {
|
|
231
|
+
...process.env,
|
|
232
|
+
RR_MESSAGE: msg.text,
|
|
233
|
+
RR_FROM: msg.from,
|
|
234
|
+
RR_TO: msg.to,
|
|
235
|
+
RR_MSG_ID: String(msg.id),
|
|
236
|
+
RR_CHANNEL: args.channel,
|
|
237
|
+
RR_PRIORITY: msg.priority ?? "default",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
child.on("error", (err) => {
|
|
241
|
+
console.error(`[listen-here] --on-message spawn failed:`, err.message);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Issue a GET request and return the IncomingMessage. We use node:https/http
|
|
246
|
+
* directly (instead of global `fetch`) so listen-here works on Node 16. */
|
|
247
|
+
function openHttpStream(url, headers, abortSignal) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const reqFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
250
|
+
const req = reqFn({
|
|
251
|
+
method: "GET",
|
|
252
|
+
host: url.hostname,
|
|
253
|
+
port: url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
|
|
254
|
+
path: url.pathname + url.search,
|
|
255
|
+
headers,
|
|
256
|
+
}, (res) => resolve(res));
|
|
257
|
+
req.on("error", reject);
|
|
258
|
+
if (abortSignal.aborted) {
|
|
259
|
+
req.destroy();
|
|
260
|
+
reject(new Error("AbortError: signal was already aborted"));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const onAbort = () => {
|
|
264
|
+
req.destroy();
|
|
265
|
+
reject(new Error("AbortError"));
|
|
266
|
+
};
|
|
267
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
268
|
+
req.end();
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/** Open one SSE connection and consume events until it closes. Returns the last
|
|
272
|
+
* seen message id (so the reconnect loop can resume from there) and the reason
|
|
273
|
+
* the connection ended ("aborted" for user-initiated, "ended" for network/server). */
|
|
274
|
+
async function runOneConnection(args, sinceCursor, abortSignal) {
|
|
275
|
+
const url = new URL(`${args.origin.replace(/\/$/, "")}/api/channels/${args.channel}/stream`);
|
|
276
|
+
if (sinceCursor !== undefined)
|
|
277
|
+
url.searchParams.set("since", String(sinceCursor));
|
|
278
|
+
let res;
|
|
279
|
+
try {
|
|
280
|
+
res = await openHttpStream(url, {
|
|
281
|
+
authorization: `Bearer ${args.token}`,
|
|
282
|
+
"x-session-id": args.session,
|
|
283
|
+
accept: "text/event-stream",
|
|
284
|
+
}, abortSignal);
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
if (abortSignal.aborted)
|
|
288
|
+
return { lastId: sinceCursor, reason: "aborted" };
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
const status = res.statusCode ?? 0;
|
|
292
|
+
if (status < 200 || status >= 300) {
|
|
293
|
+
// Drain so the socket is freed.
|
|
294
|
+
res.resume();
|
|
295
|
+
return { lastId: sinceCursor, reason: "ended", statusError: status };
|
|
296
|
+
}
|
|
297
|
+
let lastId = sinceCursor;
|
|
298
|
+
// If the operator aborts mid-stream, destroy() the response to unblock the
|
|
299
|
+
// for-await on it.
|
|
300
|
+
const onAbort = () => res.destroy();
|
|
301
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
302
|
+
try {
|
|
303
|
+
for await (const block of readSseBlocks(res)) {
|
|
304
|
+
if (abortSignal.aborted)
|
|
305
|
+
return { lastId, reason: "aborted" };
|
|
306
|
+
const parsed = parseSseBlock(block);
|
|
307
|
+
if (!parsed)
|
|
308
|
+
continue;
|
|
309
|
+
if (parsed.event === "message") {
|
|
310
|
+
let msg;
|
|
311
|
+
try {
|
|
312
|
+
msg = JSON.parse(parsed.data);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
lastId = msg.id;
|
|
318
|
+
await dispatch(args, msg);
|
|
319
|
+
}
|
|
320
|
+
else if (parsed.event === "error") {
|
|
321
|
+
console.error(`[listen-here] server error:`, parsed.data);
|
|
322
|
+
}
|
|
323
|
+
// "hello" event ignored — we don't need the initial channel metadata
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
if (abortSignal.aborted)
|
|
328
|
+
return { lastId, reason: "aborted" };
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
333
|
+
}
|
|
334
|
+
return { lastId, reason: "ended" };
|
|
335
|
+
}
|
|
336
|
+
export async function runListenHere(argv) {
|
|
337
|
+
const parsed = parseFlags(argv);
|
|
338
|
+
if ("help" in parsed) {
|
|
339
|
+
console.log(HELP);
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
if ("error" in parsed) {
|
|
343
|
+
console.error(`error: ${parsed.error}\n`);
|
|
344
|
+
console.error(HELP);
|
|
345
|
+
return 2;
|
|
346
|
+
}
|
|
347
|
+
const args = parsed;
|
|
348
|
+
const ac = new AbortController();
|
|
349
|
+
const shutdown = (sig) => {
|
|
350
|
+
if (!args.quiet)
|
|
351
|
+
console.error(`[listen-here] received ${sig}, closing stream`);
|
|
352
|
+
ac.abort();
|
|
353
|
+
};
|
|
354
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
355
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
356
|
+
if (!args.quiet) {
|
|
357
|
+
console.error(`[listen-here] connecting to ${args.origin}/api/channels/${args.channel}/stream`);
|
|
358
|
+
}
|
|
359
|
+
let cursor = args.since;
|
|
360
|
+
let backoffMs = 1000;
|
|
361
|
+
while (!ac.signal.aborted) {
|
|
362
|
+
let result;
|
|
363
|
+
try {
|
|
364
|
+
result = await runOneConnection(args, cursor, ac.signal);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
if (ac.signal.aborted)
|
|
368
|
+
return 0;
|
|
369
|
+
console.error(`[listen-here] connection error:`, err.message);
|
|
370
|
+
result = { lastId: cursor, reason: "ended" };
|
|
371
|
+
}
|
|
372
|
+
if (result.lastId !== undefined)
|
|
373
|
+
cursor = result.lastId;
|
|
374
|
+
if (result.reason === "aborted")
|
|
375
|
+
return 0;
|
|
376
|
+
if (result.statusError !== undefined) {
|
|
377
|
+
const status = result.statusError;
|
|
378
|
+
// 4xx (except 408/429) are permanent — bail rather than spin.
|
|
379
|
+
if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
|
|
380
|
+
console.error(`[listen-here] server returned ${status} — auth or session invalid; exiting`);
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
console.error(`[listen-here] server returned ${status} — will retry`);
|
|
384
|
+
}
|
|
385
|
+
// Connection closed cleanly or with retryable error. Backoff then reconnect.
|
|
386
|
+
if (ac.signal.aborted)
|
|
387
|
+
return 0;
|
|
388
|
+
const wait = backoffMs;
|
|
389
|
+
backoffMs = Math.min(60_000, Math.floor(backoffMs * 3));
|
|
390
|
+
if (!args.quiet) {
|
|
391
|
+
console.error(`[listen-here] reconnecting in ${wait}ms (cursor=${cursor ?? "none"})`);
|
|
392
|
+
}
|
|
393
|
+
await new Promise((resolve) => {
|
|
394
|
+
const t = setTimeout(resolve, wait);
|
|
395
|
+
ac.signal.addEventListener("abort", () => {
|
|
396
|
+
clearTimeout(t);
|
|
397
|
+
resolve();
|
|
398
|
+
}, { once: true });
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return 0;
|
|
402
|
+
}
|