@slack/radar-mcp 1.8.0 → 1.9.1

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,366 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ /**
5
+ * Appends every streamed SSE frame (network, rtm, clog, log, trace) to a
6
+ * per-session file so the full session record survives buffer eviction. The
7
+ * device rings and the host push ring both drop the oldest entry once full, so
8
+ * a long session loses its early events; the spool keeps the whole session. It
9
+ * does not replace the rings. The device ring stays the source of truth: it
10
+ * holds full payloads for detail and search, carries history from before the
11
+ * host connected, and serves the dashboard and every /api endpoint. This spool
12
+ * is a downstream summary log.
13
+ *
14
+ * It captures every frame delivered to the host, not every frame the device
15
+ * produced. Frames dropped during a transient device reconnect (callRadar tears
16
+ * the stream down and reconnects) never reach bufferEvent, so they are not
17
+ * spooled. Capture starts when the stream connects; there is no device backfill.
18
+ *
19
+ * A profile switch mid-session (activate_for_user) keeps appending to the same
20
+ * file, so two profiles' frames merge with no per-frame profile tag. Fine while
21
+ * nothing reads the spool; splitting by profile is for whoever adds the reader.
22
+ *
23
+ * Modeled on logcat-capture.ts: a session-scoped host file with a time-based GC,
24
+ * exit-handler cleanup, and safe rename-not-truncate rotation. One deliberate
25
+ * difference: no idle timeout. logcat idles out because a read can come long
26
+ * after the last write; this spool must hold the entire session, so it stops
27
+ * only on explicit disable or process exit.
28
+ *
29
+ * Lifecycle: startSpool when the session is enabled; stopSpool (which closes the
30
+ * fd but keeps the file) on explicit disable and on process exit, so a just-ended
31
+ * session stays readable until the GC reclaims it. Never on a transient device
32
+ * error. callRadar clears the enabled flag on a device blip; closing the spool
33
+ * there would cut the record short on a momentary drop.
34
+ */
35
+ // Under os.tmpdir() (not ~/.slack-radar) so the OS reclaims it if the process
36
+ // dies unclean (crash, kill -9, no disable). Our own stopSpool + exit handlers +
37
+ // the GC still run first; the temp dir is the backstop for the unclean case.
38
+ // Same choice db.ts makes for its pulled-DB scratch, and same reason.
39
+ //
40
+ // Pid-scoped like db.ts's DB_TMP: every MCP shares one RADAR_PORT, so a fixed
41
+ // dir lets two concurrent MCP processes build the same events-<now>.jsonl path
42
+ // and interleave sessions, and one process's gcOldSpools would walk the other's
43
+ // live files. Scoping by pid gives each process its own dir. initializeSpoolDir
44
+ // runs at startup; the default is the unscoped path so unit tests that never
45
+ // call it still get a usable location.
46
+ let SPOOL_DIR = path.join(os.tmpdir(), "slack-radar-events");
47
+ let ROTATION_BYTES = 100 * 1024 * 1024;
48
+ const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
49
+ // Cheap-cadence rotation check: run needsRotation only every Nth write so the
50
+ // hot path is a plain fd append almost always.
51
+ const ROTATION_CHECK_EVERY = 1000;
52
+ const state = {
53
+ fd: null,
54
+ filePath: null,
55
+ startedAt: null,
56
+ writeCount: 0,
57
+ };
58
+ // One-time breadcrumb latch: if writeFrame ever fails, log once so a spool that
59
+ // dies mid-session (ENOSPC/EACCES/EBADF) is diagnosable, without spamming a line
60
+ // per frame for the rest of the session. Reset on each startSpool.
61
+ let writeErrorLogged = false;
62
+ /**
63
+ * Point the spool dir at a per-process-unique path. Call once at startup with
64
+ * process.pid. Mirrors db.ts initializeDatabasePath: every MCP shares one port,
65
+ * so a pid key is the collision-free scope.
66
+ */
67
+ export function initializeSpoolDir(scopeKey) {
68
+ SPOOL_DIR = path.join(os.tmpdir(), `slack-radar-events-${scopeKey}`);
69
+ }
70
+ function ensureSpoolDir() {
71
+ if (!fs.existsSync(SPOOL_DIR)) {
72
+ // 0700: the spool holds raw bodies, auth headers, and message content under
73
+ // a world-traversable tmpdir, so scope it to the owner. Same choice db.ts
74
+ // makes for its pulled-DB scratch in the same location.
75
+ fs.mkdirSync(SPOOL_DIR, { recursive: true, mode: 0o700 });
76
+ }
77
+ }
78
+ /**
79
+ * Delete spool files older than GC_TTL_MS. Runs at session enable. Bounds disk
80
+ * usage without losing recent session records. dir + now injectable for tests.
81
+ * Mirrors gcOldCaptures (logcat-capture.ts). Returns the count removed.
82
+ */
83
+ export function gcOldSpools(dir = SPOOL_DIR, now = Date.now()) {
84
+ if (!fs.existsSync(dir))
85
+ return 0;
86
+ let removed = 0;
87
+ for (const name of fs.readdirSync(dir)) {
88
+ // Match the live files and their rotated archives.
89
+ if (!name.startsWith("events-") || !name.includes(".jsonl"))
90
+ continue;
91
+ const full = path.join(dir, name);
92
+ try {
93
+ const stat = fs.statSync(full);
94
+ if (now - stat.mtimeMs > GC_TTL_MS) {
95
+ fs.unlinkSync(full);
96
+ removed += 1;
97
+ }
98
+ }
99
+ catch {
100
+ // ignore; file may have vanished between readdir and stat
101
+ }
102
+ }
103
+ return removed;
104
+ }
105
+ export function buildSpoolFilePath(now = Date.now()) {
106
+ return path.join(SPOOL_DIR, `events-${now}.jsonl`);
107
+ }
108
+ // The epoch that pins a query_session cursor to one spool file: the <ts> in events-<ts>.jsonl. It
109
+ // changes on every rotation and re-enable, so a cursor minted against one file is detectably stale
110
+ // against another. Pure inverse of buildSpoolFilePath's naming; a null path (no spool) yields "".
111
+ // Owned here next to the name it strips so the mint and the check can never drift on the affixes.
112
+ export function epochFromSpoolPath(filePath) {
113
+ if (filePath === null)
114
+ return "";
115
+ return path.basename(filePath).replace(/^events-/, "").replace(/\.jsonl$/, "");
116
+ }
117
+ export function isSpooling() {
118
+ return state.fd !== null;
119
+ }
120
+ export function getSpoolFilePath() {
121
+ return state.filePath;
122
+ }
123
+ /**
124
+ * Read the current session spool back into parsed frames, oldest first (append
125
+ * order). Separate from the write path; reads the live file by its own handle,
126
+ * never touching the writer's fd or state. Returns [] when no spool is open, the
127
+ * file is gone, or it cannot be read. Never throws into the caller: a query over
128
+ * a missing or damaged spool must degrade to empty, not fail the tool.
129
+ *
130
+ * Reads the whole file and lets the query shaper's byte cap bound the OUTPUT,
131
+ * rather than tailing here: a filter can match an old frame near the top, so a
132
+ * byte-tail read could drop a frame the caller asked for. Rotation already caps
133
+ * the live file at a normal-session size, so a whole read is safe.
134
+ *
135
+ * A torn final line (the process died mid-append, before the newline) is skipped
136
+ * so one bad tail line never sinks the whole read. Any line that will not parse
137
+ * is dropped the same way.
138
+ */
139
+ export function readSpool() {
140
+ const filePath = state.filePath;
141
+ if (filePath === null)
142
+ return [];
143
+ let raw;
144
+ try {
145
+ raw = fs.readFileSync(filePath, "utf-8");
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ const frames = [];
151
+ for (const line of raw.split("\n")) {
152
+ if (line.length === 0)
153
+ continue;
154
+ try {
155
+ const parsed = JSON.parse(line);
156
+ // Only a well-formed frame: an object with a string `type` and an `event` object. A bare
157
+ // null/number/string (valid JSON, wrong shape) is dropped so a reader can dereference
158
+ // frame.event and frame.type without a guard on every field.
159
+ if (typeof parsed === "object" &&
160
+ parsed !== null &&
161
+ typeof parsed.type === "string" &&
162
+ typeof parsed.event === "object" &&
163
+ parsed.event !== null) {
164
+ frames.push(parsed);
165
+ }
166
+ }
167
+ catch {
168
+ // Torn or malformed line: skip it, keep the rest.
169
+ }
170
+ }
171
+ return frames;
172
+ }
173
+ /**
174
+ * Open a fresh spool file for append and set module state. Ensures the dir,
175
+ * runs gcOldSpools first, returns the path (or null on failure). Idempotent: a
176
+ * second call while a spool is open returns the current path without reopening.
177
+ */
178
+ export function startSpool(now = Date.now()) {
179
+ if (isSpooling())
180
+ return state.filePath;
181
+ try {
182
+ ensureSpoolDir();
183
+ gcOldSpools();
184
+ const filePath = buildSpoolFilePath(now);
185
+ // 0600 for the same reason as the 0700 dir: the file is raw captured
186
+ // traffic, readable only by the owner. Matches db.ts's pulled-DB scratch.
187
+ const fd = fs.openSync(filePath, "a", 0o600);
188
+ state.fd = fd;
189
+ state.filePath = filePath;
190
+ state.startedAt = now;
191
+ state.writeCount = 0;
192
+ writeErrorLogged = false;
193
+ return filePath;
194
+ }
195
+ catch {
196
+ // Failed to open the spool. Leave state clean so writeFrame no-ops.
197
+ state.fd = null;
198
+ state.filePath = null;
199
+ state.startedAt = null;
200
+ state.writeCount = 0;
201
+ return null;
202
+ }
203
+ }
204
+ /**
205
+ * Append one frame as a single JSONL line. On the hot path (every streamed
206
+ * event) and inside the wait_for_events notify path. Synchronous on purpose:
207
+ * a sync append keeps frames in arrival order with no interleave, which an async
208
+ * write queue would not guarantee. It is also cheap and never lets an error
209
+ * reach the caller, so a spool write failure can never break bufferEvent or
210
+ * waiter delivery. No-op if no spool is open. Errors are swallowed after one
211
+ * breadcrumb.
212
+ */
213
+ export function writeFrame(frame) {
214
+ if (state.fd === null)
215
+ return;
216
+ try {
217
+ // Loop until the whole buffer is written. fs.writeSync can short-write on a
218
+ // signal or a near-full disk; a partial line with no newline would fuse
219
+ // onto the next frame and break the JSONL boundary a reader would need.
220
+ const buf = Buffer.from(JSON.stringify(frame) + "\n");
221
+ let offset = 0;
222
+ while (offset < buf.length) {
223
+ offset += fs.writeSync(state.fd, buf, offset, buf.length - offset);
224
+ }
225
+ state.writeCount += 1;
226
+ if (state.writeCount % ROTATION_CHECK_EVERY === 0) {
227
+ rotateSpoolIfNeeded();
228
+ }
229
+ }
230
+ catch (err) {
231
+ // Disk full, EACCES, closed fd: swallow so the caller's waiter delivery is
232
+ // never affected. Log once so a spool that quietly died mid-session is
233
+ // diagnosable instead of silent.
234
+ if (!writeErrorLogged) {
235
+ writeErrorLogged = true;
236
+ console.error(`[radar] event spool write failed, spool may be truncated: ${err}`);
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Close the fd and clear state, but KEEP the file on disk. Called on explicit
242
+ * disable and on process exit. The file survives so a just-ended session is
243
+ * still readable; the GC reclaims it after the TTL. This matches logcat, which
244
+ * also keeps its capture file on disable and lets the GC reclaim it. Never
245
+ * called on the transient device-error path.
246
+ */
247
+ export function stopSpool() {
248
+ const fd = state.fd;
249
+ state.fd = null;
250
+ state.filePath = null;
251
+ state.startedAt = null;
252
+ state.writeCount = 0;
253
+ if (fd !== null) {
254
+ try {
255
+ fs.closeSync(fd);
256
+ }
257
+ catch {
258
+ // already closed
259
+ }
260
+ }
261
+ }
262
+ /**
263
+ * If the open file exceeds ROTATION_BYTES, rename it `.rotated-<ts>` (readable
264
+ * until the GC reclaims it) and open a fresh file. Rotation is the one place
265
+ * capture-everything yields to bounded disk: a normal session never rotates,
266
+ * this is only the disk-safety backstop.
267
+ *
268
+ * Safe rename-not-truncate, mirroring rotateCaptureIfNeeded: truncating in
269
+ * place would leave the next append past EOF. No-op if no spool is open or the
270
+ * file is under cap. Returns the new path, or null when nothing rotated.
271
+ */
272
+ export function rotateSpoolIfNeeded(now = Date.now(), capBytes = ROTATION_BYTES) {
273
+ const fd = state.fd;
274
+ const current = state.filePath;
275
+ if (fd === null || !current)
276
+ return null;
277
+ let size;
278
+ try {
279
+ size = fs.fstatSync(fd).size;
280
+ }
281
+ catch {
282
+ return null;
283
+ }
284
+ if (size <= capBytes)
285
+ return null;
286
+ try {
287
+ fs.closeSync(fd);
288
+ }
289
+ catch {
290
+ // already closed
291
+ }
292
+ state.fd = null;
293
+ try {
294
+ fs.renameSync(current, `${current}.rotated-${now}`);
295
+ }
296
+ catch {
297
+ // Rename failure is non-fatal: the file may have vanished. Fall through
298
+ // and open a fresh spool.
299
+ }
300
+ // Reopen a fresh file. startSpool is a no-op while a spool is open, so we
301
+ // cleared state.fd above first.
302
+ const reopened = startSpool(now);
303
+ if (reopened === null) {
304
+ // Reopen failed (disk full is exactly when rotation fires). state.fd stays
305
+ // null, so writeFrame no-ops for the rest of the session. Leave one
306
+ // breadcrumb so a dead-mid-session spool is diagnosable. A reopen-retry is
307
+ // out of scope here.
308
+ console.error("[radar] event spool rotation reopen failed; spool stopped");
309
+ }
310
+ return reopened;
311
+ }
312
+ /**
313
+ * Install once at process start so the spool file is deleted when the MCP
314
+ * server exits. Copies logcat-capture.ts registerCleanup, including the
315
+ * SIGINT/SIGTERM exit codes 130/143.
316
+ */
317
+ let cleanupRegistered = false;
318
+ export function registerCleanup() {
319
+ if (cleanupRegistered)
320
+ return;
321
+ cleanupRegistered = true;
322
+ const cleanup = () => stopSpool();
323
+ process.on("exit", cleanup);
324
+ process.on("SIGINT", () => {
325
+ cleanup();
326
+ process.exit(130);
327
+ });
328
+ process.on("SIGTERM", () => {
329
+ cleanup();
330
+ process.exit(143);
331
+ });
332
+ }
333
+ // Test-only: point the spool at an isolated dir so a test never writes to the
334
+ // real one. Production uses initializeSpoolDir(pid) at startup instead.
335
+ export function _setSpoolDirForTest(dir) {
336
+ SPOOL_DIR = dir;
337
+ }
338
+ // Test-only: lower the rotation cap so the periodic writeFrame check can drive a
339
+ // real rename without writing 100MB. The internal rotate call reads this default.
340
+ export function _setRotationBytesForTest(n) {
341
+ ROTATION_BYTES = n;
342
+ }
343
+ // Test-only: lower the write count so a test can trigger the periodic rotation
344
+ // check without writing ROTATION_CHECK_EVERY frames. Not for production use.
345
+ export function _setWriteCountForTest(n) {
346
+ state.writeCount = n;
347
+ }
348
+ // Test-only: the periodic rotation cadence, so a test can drive writeFrame to
349
+ // the rotation branch without hard-coding the constant.
350
+ export function _getRotationCheckEveryForTest() {
351
+ return ROTATION_CHECK_EVERY;
352
+ }
353
+ // Test-only: close the underlying fd WITHOUT clearing state, so a test can
354
+ // prove writeFrame swallows a write-to-closed-fd error. Production code must
355
+ // never leave a closed fd in state; only stopSpool/rotate close it, and both
356
+ // null the fd.
357
+ export function _closeFdLeavingStateForTest() {
358
+ if (state.fd !== null) {
359
+ try {
360
+ fs.closeSync(state.fd);
361
+ }
362
+ catch {
363
+ // already closed
364
+ }
365
+ }
366
+ }
@@ -102,6 +102,28 @@ export declare function pickDisplay(displays: DisplayInfo[]): {
102
102
  };
103
103
  /** Strip any text prefix (e.g. a multi-display warning) before the PNG magic header. */
104
104
  export declare function stripPngPrefix(raw: Buffer): Buffer;
105
+ /**
106
+ * Clamp a requested frame rate to a safe positive integer for use in an ffmpeg `fps=` filter.
107
+ * The rate ultimately comes from a client-controlled `?fps=` query param, and a raw number
108
+ * threaded into `fps=${n}` is a footgun: a negative or Infinity value makes ffmpeg write a
109
+ * 0-byte file (silent no-recording), a fractional value produces a stuttering near-empty clip,
110
+ * and a huge value hangs ffmpeg (a leaked, never-exiting encoder). Round to an integer and bound
111
+ * to [MIN_FPS, MAX_FPS]; anything non-finite or out of range falls back to DEFAULT_FPS.
112
+ */
113
+ export declare function clampFps(fps: number): number;
114
+ /**
115
+ * ffmpeg args for the record-to-mp4 pipeline: the live cast's concatenated JPEG stream on
116
+ * stdin -> a wall-clock-timed, constant-fps H.264 mp4 at `out`. Pure + exported so the arg
117
+ * SHAPE is unit-testable: the recording is a spawned subprocess with no device-free way to
118
+ * assert timing, and two arg bugs have hidden here — a fixed input `-r` (stamped every frame
119
+ * at 1/fps, slow motion) and `-use_wallclock_as_timestamps` paired with `-f image2pipe`
120
+ * (which ignores the flag and re-stamps a synthetic rate). The invariants the test pins:
121
+ * no `-r` before `-i`; the wallclock flag paired with the mjpeg demuxer that honors it (NOT
122
+ * image2pipe); and an output `fps` resample so playback is real-time and CFR. fps is clamped
123
+ * defensively here too, so the filter string is always a safe integer even if a caller skips
124
+ * the boundary clamp.
125
+ */
126
+ export declare function recordingArgs(fps: number, out: string): string[];
105
127
  /** Detect (and cache) the active display id. resetScreenState() forces a re-detect. */
106
128
  export declare function detectDisplay(): Promise<string | null>;
107
129
  export declare function displayNote(): string;
@@ -120,7 +142,33 @@ interface LiveCast {
120
142
  recording: Recording | null;
121
143
  fps: number;
122
144
  keepalive: NodeJS.Timeout | null;
145
+ orphanTimer: NodeJS.Timeout | null;
123
146
  }
147
+ /**
148
+ * Is this viewer's socket still open and writable? A closed/destroyed response is dead.
149
+ * Exported for unit testing: this predicate decides whether pushFrame prunes a viewer,
150
+ * so a regression here would either leak (never prune) or drop live viewers (prune too eagerly).
151
+ */
152
+ export declare function viewerAlive(r: NodeJS.WritableStream): boolean;
153
+ /**
154
+ * Pure decision for what to do once a viewer is gone, given how many viewers remain and
155
+ * whether a recording is running. Extracted from afterViewerRemoved so the loop-closing
156
+ * logic is unit-testable without spawning: this is the gate that decides between leaking
157
+ * (keep the cast) and reaping. "watched" = a viewer is still attached, keep the cast as-is;
158
+ * "arm-grace" = unwatched but recording, hold briefly for a clean stop; "reap" = nothing
159
+ * left, tear down now.
160
+ */
161
+ export declare function nextViewerAction(viewers: number, recording: boolean): "watched" | "arm-grace" | "reap";
162
+ /**
163
+ * The literal respawn root cause, as a pure predicate: on a screenrecord/ffmpeg exit, relaunch
164
+ * a fresh cast ONLY while a viewer is still watching. This is the exact gate onExit turns on
165
+ * ("if (viewers.size || recording) relaunch" was the bug — a recording alone respawned forever
166
+ * with no viewer). Extracted pure so the root-cause line is unit-testable, not only device-verified.
167
+ * Note the asymmetry with nextViewerAction: a recording does NOT keep the cast alive here (onExit
168
+ * lets cleanupLive finalize the clip + reap); it only defers reaping in the viewer-removal path
169
+ * via the orphan grace. So relaunch depends on viewers ALONE.
170
+ */
171
+ export declare function shouldRelaunch(viewers: number): boolean;
124
172
  /**
125
173
  * Start (or return the existing) live cast. Returns null if ffmpeg is unavailable
126
174
  * — callers must degrade rather than assume a stream. The boundary string is