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.
@@ -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
+ }