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 +42 -4
- package/dist/listen-here.js +137 -8
- package/package.json +1 -1
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
|
-
|
|
14
|
-
|
|
15
|
-
import
|
|
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({
|
package/dist/listen-here.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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)
|
|
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
|
-
|
|
476
|
-
|
|
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
|
+
"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": [
|