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.
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/assets/logo.svg +30 -0
- package/assets/og-image.png +0 -0
- package/dist/account-ui.js +895 -0
- package/dist/accounts.js +253 -0
- package/dist/admin.js +303 -0
- package/dist/agentcard.js +76 -0
- package/dist/app.js +1140 -0
- package/dist/channel.js +526 -0
- package/dist/cli.js +158 -0
- package/dist/connect.js +224 -0
- package/dist/discovery.js +569 -0
- package/dist/email.js +67 -0
- package/dist/ids.js +24 -0
- package/dist/landing.js +558 -0
- package/dist/listen-here.js +491 -0
- package/dist/mcp.js +787 -0
- package/dist/policy.js +162 -0
- package/dist/presets.js +113 -0
- package/dist/receive-recipe.js +133 -0
- package/dist/remote-control.js +123 -0
- package/dist/remote-ui.js +850 -0
- package/dist/server.js +13 -0
- package/dist/stats.js +67 -0
- package/dist/store.js +228 -0
- package/dist/transcripts.js +68 -0
- package/dist/webhooks.js +154 -0
- package/package.json +77 -0
|
@@ -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
|
+
}
|