rogerthat 1.21.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.
@@ -0,0 +1,491 @@
1
+ // `npx rogerthat 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, writeFileSync } from "node:fs";
20
+ import { request as httpRequest } from "node:http";
21
+ import { request as httpsRequest } from "node:https";
22
+ import { basename, dirname, join } from "node:path";
23
+ import { parseArgs } from "node:util";
24
+ const HELP = `rogerthat listen-here — open an SSE receiver for a channel
25
+
26
+ usage:
27
+ rogerthat 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> RogerThat origin (default: https://rogerthat.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, RR_REPLIES, RR_ATTACHMENTS are set
40
+ (RR_PRIORITY = "default" when omitted;
41
+ RR_REPLIES = tab-separated suggested replies, empty if none;
42
+ RR_ATTACHMENTS = tab-separated paths to inline attachment
43
+ files saved by listen-here, empty if none)
44
+ --inbox <file> append each message to this file (parent dir created if
45
+ missing). Format controlled by --format. When messages
46
+ carry inline attachments, the binaries are saved into a
47
+ sibling directory (e.g. '<inbox>-attachments/') and the
48
+ paths surface in the line (text format) or as a
49
+ 'saved_paths' field (jsonl format) so the receiving
50
+ agent can Read them by path.
51
+ --format <fmt> output format for stdout and --inbox lines:
52
+ jsonl (default) — one JSON object per line: {id,from,to,text,at,priority?,suggested_replies?}
53
+ text — one line per message:
54
+ "[<priority>] [<from>] <text> → [r1] [r2]"
55
+ where [<priority>] and → [r1] [r2] only appear
56
+ when set. Newlines in text collapsed to spaces.
57
+ Use this when feeding a Monitor/tail consumer
58
+ so each line is a human-readable notification
59
+ with no parser needed in the watcher.
60
+ --min-priority <p> drop messages below this priority (min|low|default|high|urgent).
61
+ Filtered messages do NOT write to --inbox, fire --on-message,
62
+ or print to stdout. Use for park-style channels: stay
63
+ wake-able only on real signals, let chatter accumulate silently.
64
+ --quiet suppress the default stdout dump of each message
65
+
66
+ if neither --on-message nor --inbox is given, messages print to stdout (one
67
+ line per message in the chosen --format).
68
+
69
+ NOTE: --on-message env vars (RR_MESSAGE, etc.) are always the raw structured
70
+ values regardless of --format. The format flag only affects what gets written
71
+ to stdout / --inbox.
72
+
73
+ cost: zero idle tokens. The process holds one long-lived HTTPS connection
74
+ to RogerThat and only fires --on-message when a real message arrives. Polling
75
+ agents pay tokens on every wake-up; this pays none.
76
+
77
+ examples:
78
+ # Dump to a file the agent's Monitor tool can tail (human-readable lines):
79
+ rogerthat listen-here --channel ch1 --token t --session s \\
80
+ --inbox /tmp/rr.log --format text
81
+
82
+ # Same, but JSONL for programmatic consumers:
83
+ rogerthat listen-here --channel ch1 --token t --session s --inbox /tmp/rr.jsonl
84
+
85
+ # Wake a parked Claude Code session each time a message arrives:
86
+ rogerthat listen-here --channel ch1 --token t --session s \\
87
+ --on-message 'claude -p "rogerthat msg from $RR_FROM: $RR_MESSAGE"'
88
+ `;
89
+ const PRIORITY_RANK = {
90
+ min: 0,
91
+ low: 1,
92
+ default: 2,
93
+ high: 3,
94
+ urgent: 4,
95
+ };
96
+ function parseFlags(argv) {
97
+ let parsed;
98
+ try {
99
+ parsed = parseArgs({
100
+ args: argv,
101
+ options: {
102
+ channel: { type: "string" },
103
+ token: { type: "string" },
104
+ session: { type: "string" },
105
+ origin: { type: "string" },
106
+ since: { type: "string" },
107
+ "on-message": { type: "string" },
108
+ inbox: { type: "string" },
109
+ format: { type: "string" },
110
+ "min-priority": { type: "string" },
111
+ quiet: { type: "boolean" },
112
+ help: { type: "boolean", short: "h" },
113
+ },
114
+ strict: true,
115
+ allowPositionals: false,
116
+ });
117
+ }
118
+ catch (e) {
119
+ return { error: e.message };
120
+ }
121
+ if (parsed.values.help)
122
+ return { help: true };
123
+ const channel = parsed.values.channel;
124
+ const token = parsed.values.token;
125
+ const session = parsed.values.session;
126
+ if (!channel || !token || !session) {
127
+ return { error: "missing required flag(s): --channel, --token, --session" };
128
+ }
129
+ let since;
130
+ if (parsed.values.since !== undefined) {
131
+ const n = Number(parsed.values.since);
132
+ if (!Number.isFinite(n))
133
+ return { error: "--since must be numeric" };
134
+ since = n;
135
+ }
136
+ let format = "jsonl";
137
+ if (parsed.values.format !== undefined) {
138
+ if (parsed.values.format !== "jsonl" && parsed.values.format !== "text") {
139
+ return { error: "--format must be 'jsonl' or 'text'" };
140
+ }
141
+ format = parsed.values.format;
142
+ }
143
+ let minPriority;
144
+ if (parsed.values["min-priority"] !== undefined) {
145
+ const v = parsed.values["min-priority"];
146
+ if (v !== "min" && v !== "low" && v !== "default" && v !== "high" && v !== "urgent") {
147
+ return { error: "--min-priority must be one of min|low|default|high|urgent" };
148
+ }
149
+ minPriority = v;
150
+ }
151
+ return {
152
+ channel,
153
+ token,
154
+ session,
155
+ origin: parsed.values.origin ?? "https://rogerthat.chat",
156
+ since,
157
+ onMessage: parsed.values["on-message"],
158
+ inbox: parsed.values.inbox,
159
+ format,
160
+ quiet: parsed.values.quiet === true,
161
+ minPriority,
162
+ };
163
+ }
164
+ /** Sanitize a filename for use on disk: strip path separators, NUL, leading
165
+ * dots, and cap length. Empty input → a placeholder. */
166
+ function safeBaseName(name) {
167
+ const cleaned = name.replace(/[/\\\0]/g, "_").replace(/^\.+/, "").slice(0, 96).trim();
168
+ return cleaned || "attachment";
169
+ }
170
+ /** Save attachments to disk alongside the inbox file. Returns the saved file
171
+ * paths (in attachment order). Without --inbox we have no directory context,
172
+ * so skip the save and return an empty list — the line still surfaces
173
+ * metadata so the agent knows something arrived.
174
+ *
175
+ * Layout: if inbox is `/tmp/rr-X.log`, attachments go in
176
+ * `/tmp/rr-X-attachments/<msg_id>-<idx>-<safe_filename>`. This puts the
177
+ * binaries next to the line that announces them so an agent monitoring the
178
+ * inbox via tail can Read them by path directly. */
179
+ function saveAttachments(args, msg) {
180
+ if (!args.inbox || !msg.attachments || msg.attachments.length === 0)
181
+ return [];
182
+ // Strip a trailing extension so `/tmp/rr-X.log` → `/tmp/rr-X-attachments`.
183
+ const inboxDir = dirname(args.inbox);
184
+ const inboxBase = basename(args.inbox).replace(/\.[^.]+$/, "");
185
+ const attDir = join(inboxDir, `${inboxBase}-attachments`);
186
+ try {
187
+ if (!existsSync(attDir))
188
+ mkdirSync(attDir, { recursive: true, mode: 0o700 });
189
+ }
190
+ catch (err) {
191
+ console.error(`[listen-here] failed to create attachment dir ${attDir}:`, err.message);
192
+ return [];
193
+ }
194
+ const saved = [];
195
+ for (let i = 0; i < msg.attachments.length; i++) {
196
+ const att = msg.attachments[i];
197
+ const safeName = safeBaseName(att.filename ?? `att-${i}`);
198
+ const path = join(attDir, `${msg.id}-${i}-${safeName}`);
199
+ try {
200
+ writeFileSync(path, Buffer.from(att.data_base64, "base64"), { mode: 0o600 });
201
+ saved.push(path);
202
+ }
203
+ catch (err) {
204
+ console.error(`[listen-here] failed to save attachment ${path}:`, err.message);
205
+ }
206
+ }
207
+ return saved;
208
+ }
209
+ /** Yield one SSE block (the lines between two blank-line separators) at a time.
210
+ * Takes a Node IncomingMessage rather than a Fetch-style ReadableStream so we
211
+ * don't need global `fetch` — listen-here must run on Node 16, which lacks
212
+ * it. IncomingMessage is async-iterable since Node 10. */
213
+ async function* readSseBlocks(body) {
214
+ body.setEncoding("utf8");
215
+ let buffer = "";
216
+ for await (const chunk of body) {
217
+ buffer += chunk;
218
+ let idx;
219
+ while ((idx = buffer.indexOf("\n\n")) !== -1) {
220
+ const block = buffer.slice(0, idx);
221
+ buffer = buffer.slice(idx + 2);
222
+ if (block.length > 0)
223
+ yield block;
224
+ }
225
+ }
226
+ if (buffer.length > 0)
227
+ yield buffer;
228
+ }
229
+ function parseSseBlock(block) {
230
+ let event = "";
231
+ const dataLines = [];
232
+ for (const line of block.split("\n")) {
233
+ if (line.startsWith(":"))
234
+ continue; // comment / heartbeat
235
+ if (line.startsWith("event: "))
236
+ event = line.slice(7);
237
+ else if (line.startsWith("data: "))
238
+ dataLines.push(line.slice(6));
239
+ }
240
+ if (!event && dataLines.length === 0)
241
+ return null;
242
+ return { event: event || "message", data: dataLines.join("\n") };
243
+ }
244
+ function formatLine(args, msg, savedPaths) {
245
+ if (args.format === "text") {
246
+ // Collapse any newlines in the body so Monitor/tail consumers get exactly
247
+ // one notification per message. Use a single space; the JSONL format is
248
+ // available for callers that need lossless body content.
249
+ const flat = msg.text.replace(/\r?\n/g, " ").trim();
250
+ // Surface non-default priority as a leading tag so a Monitor tail of the
251
+ // inbox file can grep for urgency without parsing JSON. e.g. `[urgent] `
252
+ const prio = msg.priority && msg.priority !== "default" ? `[${msg.priority}] ` : "";
253
+ // Surface suggested_replies as a trailing → [yes] [no] hint so the
254
+ // agent sees the canned options without parsing JSON.
255
+ const replies = msg.suggested_replies && msg.suggested_replies.length > 0
256
+ ? " → " + msg.suggested_replies.map((r) => `[${r}]`).join(" ")
257
+ : "";
258
+ // Surface attachment paths so the agent can Read them directly. The 📎
259
+ // marker keeps it easy to grep for. Without --inbox we never saved any
260
+ // files, but we still announce that attachments arrived so the agent
261
+ // can fall back to the JSONL stream or `history` over MCP to fetch them.
262
+ let attTag = "";
263
+ if (savedPaths.length > 0) {
264
+ attTag = " 📎 " + savedPaths.join(", ");
265
+ }
266
+ else if (msg.attachments && msg.attachments.length > 0) {
267
+ const summary = msg.attachments
268
+ .map((a) => `${a.mime}${a.filename ? ` "${a.filename}"` : ""}`)
269
+ .join(", ");
270
+ attTag = ` 📎 (${msg.attachments.length} inline, not saved: ${summary})`;
271
+ }
272
+ return `${prio}[${msg.from}] ${flat}${replies}${attTag}`;
273
+ }
274
+ // JSONL: pass the wire shape through, plus saved_paths so a jq consumer can
275
+ // pick the on-disk locations without re-base64-decoding the body.
276
+ if (savedPaths.length > 0) {
277
+ return JSON.stringify({ ...msg, saved_paths: savedPaths });
278
+ }
279
+ return JSON.stringify(msg);
280
+ }
281
+ async function dispatch(args, msg) {
282
+ // --min-priority filter: drop messages below the threshold entirely (no
283
+ // inbox write, no hook spawn, no stdout). Missing priority counts as
284
+ // "default" (rank 2).
285
+ if (args.minPriority) {
286
+ const incomingRank = PRIORITY_RANK[msg.priority ?? "default"];
287
+ if (incomingRank < PRIORITY_RANK[args.minPriority])
288
+ return;
289
+ }
290
+ // Save inline attachments to disk (alongside the inbox) BEFORE writing the
291
+ // line, so the path the line announces actually exists when the agent's
292
+ // Monitor tool fires on the new tail entry.
293
+ const savedPaths = saveAttachments(args, msg);
294
+ const line = formatLine(args, msg, savedPaths);
295
+ if (args.inbox) {
296
+ const dir = dirname(args.inbox);
297
+ if (dir && !existsSync(dir))
298
+ mkdirSync(dir, { recursive: true });
299
+ appendFileSync(args.inbox, line + "\n", { mode: 0o600 });
300
+ }
301
+ if (!args.inbox && !args.onMessage && !args.quiet) {
302
+ process.stdout.write(line + "\n");
303
+ }
304
+ if (args.onMessage) {
305
+ // child stdio inherits so the agent can see whatever the hook prints.
306
+ // We DON'T await — the hook can be slow (e.g. `claude -p ...`); we want
307
+ // to keep consuming the SSE stream in parallel so backed-up messages
308
+ // don't block live ones. The shell command can itself sequence if needed.
309
+ const child = spawn(args.onMessage, {
310
+ shell: true,
311
+ stdio: "inherit",
312
+ env: {
313
+ ...process.env,
314
+ RR_MESSAGE: msg.text,
315
+ RR_FROM: msg.from,
316
+ RR_TO: msg.to,
317
+ RR_MSG_ID: String(msg.id),
318
+ RR_CHANNEL: args.channel,
319
+ RR_PRIORITY: msg.priority ?? "default",
320
+ // Tab-separated so the hook can split on $'\t' cleanly even if a reply
321
+ // contains spaces or punctuation. Empty string when no suggestions.
322
+ RR_REPLIES: (msg.suggested_replies ?? []).join("\t"),
323
+ // Tab-separated paths to inline attachments saved to disk. Empty
324
+ // string when no attachments arrived OR when --inbox wasn't set (no
325
+ // directory context to save into).
326
+ RR_ATTACHMENTS: savedPaths.join("\t"),
327
+ },
328
+ });
329
+ child.on("error", (err) => {
330
+ console.error(`[listen-here] --on-message spawn failed:`, err.message);
331
+ });
332
+ }
333
+ }
334
+ /** Issue a GET request and return the IncomingMessage. We use node:https/http
335
+ * directly (instead of global `fetch`) so listen-here works on Node 16. */
336
+ function openHttpStream(url, headers, abortSignal) {
337
+ return new Promise((resolve, reject) => {
338
+ const reqFn = url.protocol === "https:" ? httpsRequest : httpRequest;
339
+ const req = reqFn({
340
+ method: "GET",
341
+ host: url.hostname,
342
+ port: url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
343
+ path: url.pathname + url.search,
344
+ headers,
345
+ }, (res) => resolve(res));
346
+ req.on("error", reject);
347
+ if (abortSignal.aborted) {
348
+ req.destroy();
349
+ reject(new Error("AbortError: signal was already aborted"));
350
+ return;
351
+ }
352
+ const onAbort = () => {
353
+ req.destroy();
354
+ reject(new Error("AbortError"));
355
+ };
356
+ abortSignal.addEventListener("abort", onAbort, { once: true });
357
+ req.end();
358
+ });
359
+ }
360
+ /** Open one SSE connection and consume events until it closes. Returns the last
361
+ * seen message id (so the reconnect loop can resume from there) and the reason
362
+ * the connection ended ("aborted" for user-initiated, "ended" for network/server). */
363
+ async function runOneConnection(args, sinceCursor, abortSignal) {
364
+ const url = new URL(`${args.origin.replace(/\/$/, "")}/api/channels/${args.channel}/stream`);
365
+ if (sinceCursor !== undefined)
366
+ url.searchParams.set("since", String(sinceCursor));
367
+ let res;
368
+ try {
369
+ res = await openHttpStream(url, {
370
+ authorization: `Bearer ${args.token}`,
371
+ "x-session-id": args.session,
372
+ accept: "text/event-stream",
373
+ }, abortSignal);
374
+ }
375
+ catch (err) {
376
+ if (abortSignal.aborted)
377
+ return { lastId: sinceCursor, reason: "aborted" };
378
+ throw err;
379
+ }
380
+ const status = res.statusCode ?? 0;
381
+ if (status < 200 || status >= 300) {
382
+ // Drain so the socket is freed.
383
+ res.resume();
384
+ return { lastId: sinceCursor, reason: "ended", statusError: status };
385
+ }
386
+ let lastId = sinceCursor;
387
+ // If the operator aborts mid-stream, destroy() the response to unblock the
388
+ // for-await on it.
389
+ const onAbort = () => res.destroy();
390
+ abortSignal.addEventListener("abort", onAbort, { once: true });
391
+ try {
392
+ for await (const block of readSseBlocks(res)) {
393
+ if (abortSignal.aborted)
394
+ return { lastId, reason: "aborted" };
395
+ const parsed = parseSseBlock(block);
396
+ if (!parsed)
397
+ continue;
398
+ if (parsed.event === "message") {
399
+ let msg;
400
+ try {
401
+ msg = JSON.parse(parsed.data);
402
+ }
403
+ catch {
404
+ continue;
405
+ }
406
+ lastId = msg.id;
407
+ await dispatch(args, msg);
408
+ }
409
+ else if (parsed.event === "error") {
410
+ console.error(`[listen-here] server error:`, parsed.data);
411
+ }
412
+ // "hello" event ignored — we don't need the initial channel metadata
413
+ }
414
+ }
415
+ catch (err) {
416
+ if (abortSignal.aborted)
417
+ return { lastId, reason: "aborted" };
418
+ throw err;
419
+ }
420
+ finally {
421
+ abortSignal.removeEventListener("abort", onAbort);
422
+ }
423
+ return { lastId, reason: "ended" };
424
+ }
425
+ export async function runListenHere(argv) {
426
+ const parsed = parseFlags(argv);
427
+ if ("help" in parsed) {
428
+ console.log(HELP);
429
+ return 0;
430
+ }
431
+ if ("error" in parsed) {
432
+ console.error(`error: ${parsed.error}\n`);
433
+ console.error(HELP);
434
+ return 2;
435
+ }
436
+ const args = parsed;
437
+ const ac = new AbortController();
438
+ const shutdown = (sig) => {
439
+ if (!args.quiet)
440
+ console.error(`[listen-here] received ${sig}, closing stream`);
441
+ ac.abort();
442
+ };
443
+ process.once("SIGINT", () => shutdown("SIGINT"));
444
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
445
+ if (!args.quiet) {
446
+ console.error(`[listen-here] connecting to ${args.origin}/api/channels/${args.channel}/stream`);
447
+ }
448
+ let cursor = args.since;
449
+ let backoffMs = 1000;
450
+ while (!ac.signal.aborted) {
451
+ let result;
452
+ try {
453
+ result = await runOneConnection(args, cursor, ac.signal);
454
+ }
455
+ catch (err) {
456
+ if (ac.signal.aborted)
457
+ return 0;
458
+ console.error(`[listen-here] connection error:`, err.message);
459
+ result = { lastId: cursor, reason: "ended" };
460
+ }
461
+ if (result.lastId !== undefined)
462
+ cursor = result.lastId;
463
+ if (result.reason === "aborted")
464
+ return 0;
465
+ if (result.statusError !== undefined) {
466
+ const status = result.statusError;
467
+ // 4xx (except 408/429) are permanent — bail rather than spin.
468
+ if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
469
+ console.error(`[listen-here] server returned ${status} — auth or session invalid; exiting`);
470
+ return 1;
471
+ }
472
+ console.error(`[listen-here] server returned ${status} — will retry`);
473
+ }
474
+ // Connection closed cleanly or with retryable error. Backoff then reconnect.
475
+ if (ac.signal.aborted)
476
+ return 0;
477
+ const wait = backoffMs;
478
+ backoffMs = Math.min(60_000, Math.floor(backoffMs * 3));
479
+ if (!args.quiet) {
480
+ console.error(`[listen-here] reconnecting in ${wait}ms (cursor=${cursor ?? "none"})`);
481
+ }
482
+ await new Promise((resolve) => {
483
+ const t = setTimeout(resolve, wait);
484
+ ac.signal.addEventListener("abort", () => {
485
+ clearTimeout(t);
486
+ resolve();
487
+ }, { once: true });
488
+ });
489
+ }
490
+ return 0;
491
+ }