rogerthat 1.24.3 → 1.24.5

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/cli.js CHANGED
@@ -10,10 +10,41 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
- import { parseArgs } from "node:util";
14
- import { runListenHere } from "./listen-here.js";
15
- import { runReceiveRecipe } from "./receive-recipe.js";
13
+ // NOTE: `parseArgs` (node:util), `./listen-here.js` and `./receive-recipe.js`
14
+ // are imported DYNAMICALLY inside main(), AFTER requireModernNode(). They (and
15
+ // listen-here's own `node:util` import) hard-fail at module-load time on Node
16
+ // < 18.3 with a cryptic `SyntaxError: 'node:util' does not provide an export
17
+ // 'parseArgs'`. Loading them statically would crash before our friendly version
18
+ // check could run. Keep the top of this file dependent only on Node-16-safe
19
+ // builtins so the guard below is what the user actually sees.
16
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
21
+ /** Hard-require Node >= 18.3 (parseArgs landed there; see package.json engines).
22
+ * Prints a clear message instead of the cryptic parseArgs SyntaxError. */
23
+ function requireModernNode() {
24
+ const [maj = 0, min = 0] = process.versions.node.split(".").map((n) => Number(n));
25
+ if (maj > 18 || (maj === 18 && min >= 3))
26
+ return;
27
+ console.error(`rogerthat requires Node >= 18.3 — found v${process.versions.node}.\n` +
28
+ `Upgrade Node (e.g. \`nvm install 20 && nvm use 20\`, or https://nodejs.org) and retry.`);
29
+ process.exit(1);
30
+ }
31
+ // Load .env (no dependency on dotenv). Tries repo-root .env relative to this file.
32
+ (function loadDotEnv() {
33
+ // dist/cli.js -> repo root; src/cli.ts -> repo root (one up from src)
34
+ const candidates = [join(__dirname, "..", ".env"), join(__dirname, "..", "..", ".env")];
35
+ for (const path of candidates) {
36
+ if (!existsSync(path))
37
+ continue;
38
+ const content = readFileSync(path, "utf-8");
39
+ for (const line of content.split("\n")) {
40
+ const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
41
+ if (m && !process.env[m[1]]) {
42
+ process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
43
+ }
44
+ }
45
+ break;
46
+ }
47
+ })();
17
48
  let PKG_VERSION = "?";
18
49
  try {
19
50
  PKG_VERSION = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
@@ -63,17 +94,24 @@ function isLocalHost(host) {
63
94
  return host === "127.0.0.1" || host === "localhost" || host === "::1";
64
95
  }
65
96
  async function main() {
97
+ // Friendly Node-version gate BEFORE any module that imports node:util's
98
+ // parseArgs is loaded (this file, listen-here, receive-recipe all do).
99
+ requireModernNode();
66
100
  // Subcommand dispatch: anything before flags. Detect by argv[2] being a
67
- // non-flag word.
101
+ // non-flag word. Dynamic import so listen-here/receive-recipe (and their
102
+ // parseArgs dependency) only load after the version gate above.
68
103
  const first = process.argv[2];
69
104
  if (first === "listen-here") {
105
+ const { runListenHere } = await import("./listen-here.js");
70
106
  const code = await runListenHere(process.argv.slice(3));
71
107
  process.exit(code);
72
108
  }
73
109
  if (first === "receive-recipe") {
110
+ const { runReceiveRecipe } = await import("./receive-recipe.js");
74
111
  const code = runReceiveRecipe(process.argv.slice(3));
75
112
  process.exit(code);
76
113
  }
114
+ const { parseArgs } = await import("node:util");
77
115
  let parsed;
78
116
  try {
79
117
  parsed = parseArgs({
@@ -24,14 +24,20 @@ import { parseArgs } from "node:util";
24
24
  const HELP = `rogerthat listen-here — open an SSE receiver for a channel
25
25
 
26
26
  usage:
27
+ rogerthat listen-here --channel <id> --token <t> --identity-key <k> [options]
27
28
  rogerthat listen-here --channel <id> --token <t> --session <sid> [options]
28
29
 
29
30
  required:
30
31
  --channel <id> channel id (returned by /join or create_channel)
31
32
  --token <t> channel bearer token
32
- --session <sid> X-Session-Id from /join (the calling agent's session)
33
+ and ONE of:
34
+ --identity-key <k> auto-join the channel with this identity_key to obtain a
35
+ session (recommended — makes this command self-contained)
36
+ --session <sid> reuse an X-Session-Id you already got from /join
33
37
 
34
38
  options:
39
+ --owner-password <p> passed to the auto-join so the session is marked
40
+ human-authorized (only meaningful with --identity-key)
35
41
  --origin <url> RogerThat origin (default: https://rogerthat.chat)
36
42
  --since <msg_id> resume from a known message id (skips per-session cursor)
37
43
  --on-message <cmd> shell command to run for each delivered message; env vars
@@ -102,6 +108,8 @@ function parseFlags(argv) {
102
108
  channel: { type: "string" },
103
109
  token: { type: "string" },
104
110
  session: { type: "string" },
111
+ "identity-key": { type: "string" },
112
+ "owner-password": { type: "string" },
105
113
  origin: { type: "string" },
106
114
  since: { type: "string" },
107
115
  "on-message": { type: "string" },
@@ -123,8 +131,13 @@ function parseFlags(argv) {
123
131
  const channel = parsed.values.channel;
124
132
  const token = parsed.values.token;
125
133
  const session = parsed.values.session;
126
- if (!channel || !token || !session) {
127
- return { error: "missing required flag(s): --channel, --token, --session" };
134
+ const identityKey = parsed.values["identity-key"];
135
+ if (!channel || !token) {
136
+ return { error: "missing required flag(s): --channel, --token" };
137
+ }
138
+ // Either an explicit --session, or --identity-key to auto-join for one.
139
+ if (!session && !identityKey) {
140
+ return { error: "provide --session <sid> OR --identity-key <key> (auto-joins to get a session)" };
128
141
  }
129
142
  let since;
130
143
  if (parsed.values.since !== undefined) {
@@ -151,7 +164,9 @@ function parseFlags(argv) {
151
164
  return {
152
165
  channel,
153
166
  token,
154
- session,
167
+ session: session ?? "",
168
+ identityKey,
169
+ ownerPassword: parsed.values["owner-password"],
155
170
  origin: parsed.values.origin ?? "https://rogerthat.chat",
156
171
  since,
157
172
  onMessage: parsed.values["on-message"],
@@ -284,6 +299,30 @@ function formatLine(args, msg, savedPaths) {
284
299
  }
285
300
  return JSON.stringify(msg);
286
301
  }
302
+ /** Append a raw status line to the inbox file (if --inbox is set). Used for the
303
+ * relay's own health signals — connect / reconnect notices — so an agent's
304
+ * Monitor (a `tail -F` of the inbox) sees a confirmation that the listener is
305
+ * actually attached, instead of having to inspect `ps`/`ss` to tell a healthy
306
+ * relay from a crashed one. Written even under --quiet: quiet silences stdout,
307
+ * not the inbox's own heartbeat. The `⟲` marker keeps it greppable/skippable.
308
+ * Best-effort — a failed append must never take the relay down. */
309
+ function writeInboxStatus(args, text) {
310
+ if (!args.inbox)
311
+ return;
312
+ // Only in text mode. JSONL consumers parse each line (or the whole file) as
313
+ // JSON, so a bare `⟲ …` line would break them — they get message objects only.
314
+ if (args.format !== "text")
315
+ return;
316
+ try {
317
+ const dir = dirname(args.inbox);
318
+ if (dir && !existsSync(dir))
319
+ mkdirSync(dir, { recursive: true });
320
+ appendFileSync(args.inbox, `⟲ [listen-here] ${text}\n`, { mode: 0o600 });
321
+ }
322
+ catch {
323
+ /* never let a health-line write kill the relay */
324
+ }
325
+ }
287
326
  async function dispatch(args, msg) {
288
327
  // --min-priority filter: drop messages below the threshold entirely (no
289
328
  // inbox write, no hook spawn, no stdout). Missing priority counts as
@@ -389,6 +428,11 @@ async function runOneConnection(args, sinceCursor, abortSignal) {
389
428
  res.resume();
390
429
  return { lastId: sinceCursor, reason: "ended", statusError: status };
391
430
  }
431
+ // SSE handshake accepted → the relay is genuinely attached and will receive
432
+ // phone messages from here on. Announce it in the inbox so the agent's Monitor
433
+ // confirms "listening" without a manual selftest, and the operator's phone
434
+ // sees a peer come online. (See writeInboxStatus.)
435
+ writeInboxStatus(args, `● connected — listening on ${args.channel} as session ${(args.session ?? "").slice(0, 8)}…`);
392
436
  let lastId = sinceCursor;
393
437
  // If the operator aborts mid-stream, destroy() the response to unblock the
394
438
  // for-await on it.
@@ -428,6 +472,55 @@ async function runOneConnection(args, sinceCursor, abortSignal) {
428
472
  }
429
473
  return { lastId, reason: "ended" };
430
474
  }
475
+ /** Auto-join the channel with an identity_key to obtain a session_id, so the
476
+ * receiver command can be a single self-contained line (no separate /join,
477
+ * no `<SID>` placeholder). Uses node:http/https directly (Node-16-safe, same
478
+ * as the SSE path). Returns the session_id or throws with a clear message. */
479
+ function joinForSession(args) {
480
+ const url = new URL(`${args.origin.replace(/\/$/, "")}/api/channels/${args.channel}/join`);
481
+ const payload = JSON.stringify({
482
+ identity_key: args.identityKey,
483
+ ...(args.ownerPassword ? { owner_password: args.ownerPassword } : {}),
484
+ });
485
+ return new Promise((resolve, reject) => {
486
+ const reqFn = url.protocol === "https:" ? httpsRequest : httpRequest;
487
+ const req = reqFn({
488
+ method: "POST",
489
+ host: url.hostname,
490
+ port: url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80,
491
+ path: url.pathname + url.search,
492
+ headers: {
493
+ authorization: `Bearer ${args.token}`,
494
+ "content-type": "application/json",
495
+ "content-length": Buffer.byteLength(payload),
496
+ accept: "application/json",
497
+ },
498
+ }, (res) => {
499
+ let raw = "";
500
+ res.setEncoding("utf8");
501
+ res.on("data", (d) => (raw += d));
502
+ res.on("end", () => {
503
+ const status = res.statusCode ?? 0;
504
+ let body = {};
505
+ try {
506
+ body = JSON.parse(raw);
507
+ }
508
+ catch {
509
+ /* leave empty */
510
+ }
511
+ if (status >= 200 && status < 300 && body.session_id) {
512
+ resolve(body.session_id);
513
+ }
514
+ else {
515
+ reject(new Error(body.error || `join failed (HTTP ${status})`));
516
+ }
517
+ });
518
+ });
519
+ req.on("error", reject);
520
+ req.write(payload);
521
+ req.end();
522
+ });
523
+ }
431
524
  export async function runListenHere(argv) {
432
525
  const parsed = parseFlags(argv);
433
526
  if ("help" in parsed) {
@@ -440,6 +533,19 @@ export async function runListenHere(argv) {
440
533
  return 2;
441
534
  }
442
535
  const args = parsed;
536
+ // No explicit --session? Auto-join with the identity_key to get one. This is
537
+ // what makes the receiver command self-contained (no <SID> to fill in).
538
+ if (!args.session) {
539
+ try {
540
+ args.session = await joinForSession(args);
541
+ if (!args.quiet)
542
+ console.error(`[listen-here] joined as session ${args.session.slice(0, 8)}…`);
543
+ }
544
+ catch (err) {
545
+ console.error(`[listen-here] auto-join failed: ${err.message}`);
546
+ return 1;
547
+ }
548
+ }
443
549
  const ac = new AbortController();
444
550
  const shutdown = (sig) => {
445
551
  if (!args.quiet)
@@ -470,12 +576,35 @@ export async function runListenHere(argv) {
470
576
  return 0;
471
577
  if (result.statusError !== undefined) {
472
578
  const status = result.statusError;
473
- // 4xx (except 408/429) are permanent bail rather than spin.
579
+ // 4xx (except 408/429) usually mean our SESSION is gone, not that our
580
+ // creds are bad: a server restart wipes in-memory sessions AND tombstones,
581
+ // so the stream returns 400 not_joined (or 410) for the session we held.
582
+ // Our join creds are durable, so the robust move is to mint a FRESH
583
+ // session and resume — a restart should be transparent, not fatal. Only
584
+ // bail if we have no identity to re-join with, or the re-join itself fails
585
+ // (that's a genuinely bad token / identity_key).
474
586
  if (status >= 400 && status < 500 && status !== 408 && status !== 429) {
475
- console.error(`[listen-here] server returned ${status} auth or session invalid; exiting`);
476
- return 1;
587
+ // Re-join needs the identity_key (joinForSession auto-joins with it). If
588
+ // the listener was started with an explicit --session and no
589
+ // --identity-key, we have nothing to re-join with → bail as before.
590
+ if (!args.identityKey) {
591
+ console.error(`[listen-here] server returned ${status} and no --identity-key to re-join with; exiting`);
592
+ return 1;
593
+ }
594
+ try {
595
+ args.session = await joinForSession(args);
596
+ if (!args.quiet) {
597
+ console.error(`[listen-here] re-joined after ${status} as session ${args.session.slice(0, 8)}… — resuming`);
598
+ }
599
+ }
600
+ catch (err) {
601
+ console.error(`[listen-here] re-join after ${status} failed (${err.message}); exiting`);
602
+ return 1;
603
+ }
604
+ }
605
+ else {
606
+ console.error(`[listen-here] server returned ${status} — will retry`);
477
607
  }
478
- console.error(`[listen-here] server returned ${status} — will retry`);
479
608
  }
480
609
  // Connection closed cleanly or with retryable error. Backoff then reconnect.
481
610
  if (ac.signal.aborted)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerthat",
3
- "version": "1.24.3",
3
+ "version": "1.24.5",
4
4
  "mcpName": "io.github.opcastil11/rogerthat",
5
5
  "description": "Real-time chat for AI agents. A walkie-talkie hub that lets two or more agents — Claude Code, Cursor, Cline, Claude Desktop, Codex — on different machines send messages to each other over MCP or plain REST. Hosted at rogerthat.chat or self-hosted with `npx rogerthat`.",
6
6
  "keywords": [