@slack/radar-mcp 1.9.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.
package/dist/mcp/index.js CHANGED
@@ -668,7 +668,7 @@ async function runMcpServer() {
668
668
  }
669
669
  }
670
670
  // --- MCP Server ---
671
- const server = new Server({ name: "slack-radar", version: "1.9.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
671
+ const server = new Server({ name: "slack-radar", version: "1.9.1" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
672
672
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
673
673
  tools: TOOL_DEFINITIONS,
674
674
  }));
@@ -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
@@ -230,6 +230,53 @@ export function stripPngPrefix(raw) {
230
230
  const off = raw.indexOf(PNG_MAGIC);
231
231
  return off > 0 ? raw.subarray(off) : raw;
232
232
  }
233
+ // Bounds for a recording/live-cast frame rate. Client-controlled (?fps=), so clampFps holds
234
+ // the range in one place. MAX 60 = the device panel refresh ceiling (screenrecord cannot feed
235
+ // faster); MIN 1 = a valid non-degenerate rate; DEFAULT 20 = the measured sweet spot the UI
236
+ // uses and the fallback for any out-of-range/non-finite request.
237
+ const MIN_FPS = 1;
238
+ const MAX_FPS = 60;
239
+ const DEFAULT_FPS = 20;
240
+ /**
241
+ * Clamp a requested frame rate to a safe positive integer for use in an ffmpeg `fps=` filter.
242
+ * The rate ultimately comes from a client-controlled `?fps=` query param, and a raw number
243
+ * threaded into `fps=${n}` is a footgun: a negative or Infinity value makes ffmpeg write a
244
+ * 0-byte file (silent no-recording), a fractional value produces a stuttering near-empty clip,
245
+ * and a huge value hangs ffmpeg (a leaked, never-exiting encoder). Round to an integer and bound
246
+ * to [MIN_FPS, MAX_FPS]; anything non-finite or out of range falls back to DEFAULT_FPS.
247
+ */
248
+ export function clampFps(fps) {
249
+ const n = Math.round(fps);
250
+ return Number.isFinite(n) && n >= MIN_FPS && n <= MAX_FPS ? n : DEFAULT_FPS;
251
+ }
252
+ /**
253
+ * ffmpeg args for the record-to-mp4 pipeline: the live cast's concatenated JPEG stream on
254
+ * stdin -> a wall-clock-timed, constant-fps H.264 mp4 at `out`. Pure + exported so the arg
255
+ * SHAPE is unit-testable: the recording is a spawned subprocess with no device-free way to
256
+ * assert timing, and two arg bugs have hidden here — a fixed input `-r` (stamped every frame
257
+ * at 1/fps, slow motion) and `-use_wallclock_as_timestamps` paired with `-f image2pipe`
258
+ * (which ignores the flag and re-stamps a synthetic rate). The invariants the test pins:
259
+ * no `-r` before `-i`; the wallclock flag paired with the mjpeg demuxer that honors it (NOT
260
+ * image2pipe); and an output `fps` resample so playback is real-time and CFR. fps is clamped
261
+ * defensively here too, so the filter string is always a safe integer even if a caller skips
262
+ * the boundary clamp.
263
+ */
264
+ export function recordingArgs(fps, out) {
265
+ return [
266
+ "-hide_banner", "-loglevel", "error", "-y",
267
+ // Raw MJPEG demuxer (not image2pipe): only this demuxer honors
268
+ // -use_wallclock_as_timestamps on a piped JPEG stream, stamping each frame by its true
269
+ // arrival time regardless of frame size. image2pipe stamps a synthetic fixed rate.
270
+ "-f", "mjpeg",
271
+ "-use_wallclock_as_timestamps", "1",
272
+ "-i", "pipe:0",
273
+ // Resample the real arrival timeline to a constant fps so the mp4 is CFR (smooth/seekable
274
+ // everywhere) and its duration matches wall-clock.
275
+ "-vf", `fps=${clampFps(fps)}`,
276
+ "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast",
277
+ out,
278
+ ];
279
+ }
233
280
  // ---- active-display detection ------------------------------------------------
234
281
  let scDisplay = null;
235
282
  let scDetected = false;
@@ -275,6 +322,95 @@ export function captureScreenshot() {
275
322
  });
276
323
  }
277
324
  let scLive = null;
325
+ // When the last viewer leaves while a recording is running, wait this long for a clean
326
+ // /screen/record/stop before treating the recording as orphaned (tab closed/crashed
327
+ // before its stop POST landed). The clean stop POST arrives in well under a second; this
328
+ // window comfortably clears it while bounding an orphaned cast to a few seconds instead
329
+ // of looping screenrecord until its next self-exit (or forever, via relaunch).
330
+ const ORPHAN_GRACE_MS = 8000;
331
+ /**
332
+ * Is this viewer's socket still open and writable? A closed/destroyed response is dead.
333
+ * Exported for unit testing: this predicate decides whether pushFrame prunes a viewer,
334
+ * so a regression here would either leak (never prune) or drop live viewers (prune too eagerly).
335
+ */
336
+ export function viewerAlive(r) {
337
+ const w = r;
338
+ return w.writable !== false && w.writableEnded !== true && w.destroyed !== true;
339
+ }
340
+ /**
341
+ * Arm the orphan-grace timer: if no viewer returns and no clean stop arrives, finalize the
342
+ * orphaned recording (saved to disk) and reap the cast so screenrecord stops looping. Idempotent
343
+ * — a second call while armed is a no-op. unref'd so it never keeps the process alive on its own.
344
+ */
345
+ function armOrphanGrace(L) {
346
+ if (L.orphanTimer)
347
+ return;
348
+ L.orphanTimer = setTimeout(() => {
349
+ L.orphanTimer = null;
350
+ if (scLive === L && !L.viewers.size && L.recording) {
351
+ // .catch is defensive: stopRecording never rejects and reapLiveIfIdle is sync/guarded
352
+ // today, but this runs in a bare timer callback where an unhandled rejection would be
353
+ // process-wide, so a future edit that lets either throw must not escape here.
354
+ void stopRecording(L)
355
+ .then(() => reapLiveIfIdle())
356
+ .catch(() => {
357
+ /* best-effort orphan reap; nothing to recover to */
358
+ });
359
+ }
360
+ }, ORPHAN_GRACE_MS);
361
+ if (typeof L.orphanTimer.unref === "function")
362
+ L.orphanTimer.unref();
363
+ }
364
+ function clearOrphanGrace(L) {
365
+ if (L.orphanTimer) {
366
+ clearTimeout(L.orphanTimer);
367
+ L.orphanTimer = null;
368
+ }
369
+ }
370
+ /**
371
+ * Pure decision for what to do once a viewer is gone, given how many viewers remain and
372
+ * whether a recording is running. Extracted from afterViewerRemoved so the loop-closing
373
+ * logic is unit-testable without spawning: this is the gate that decides between leaking
374
+ * (keep the cast) and reaping. "watched" = a viewer is still attached, keep the cast as-is;
375
+ * "arm-grace" = unwatched but recording, hold briefly for a clean stop; "reap" = nothing
376
+ * left, tear down now.
377
+ */
378
+ export function nextViewerAction(viewers, recording) {
379
+ if (viewers > 0)
380
+ return "watched";
381
+ return recording ? "arm-grace" : "reap";
382
+ }
383
+ /**
384
+ * The literal respawn root cause, as a pure predicate: on a screenrecord/ffmpeg exit, relaunch
385
+ * a fresh cast ONLY while a viewer is still watching. This is the exact gate onExit turns on
386
+ * ("if (viewers.size || recording) relaunch" was the bug — a recording alone respawned forever
387
+ * with no viewer). Extracted pure so the root-cause line is unit-testable, not only device-verified.
388
+ * Note the asymmetry with nextViewerAction: a recording does NOT keep the cast alive here (onExit
389
+ * lets cleanupLive finalize the clip + reap); it only defers reaping in the viewer-removal path
390
+ * via the orphan grace. So relaunch depends on viewers ALONE.
391
+ */
392
+ export function shouldRelaunch(viewers) {
393
+ return viewers > 0;
394
+ }
395
+ /**
396
+ * Called after a viewer is removed (leave or dead-viewer prune). If the cast is now
397
+ * unwatched, either arm the orphan grace (a recording is still running) or reap it outright
398
+ * (nothing left to keep it alive). This is the single "is anyone still watching?" gate.
399
+ */
400
+ function afterViewerRemoved() {
401
+ if (!scLive)
402
+ return;
403
+ switch (nextViewerAction(scLive.viewers.size, scLive.recording !== null)) {
404
+ case "watched":
405
+ return;
406
+ case "arm-grace":
407
+ armOrphanGrace(scLive);
408
+ return;
409
+ case "reap":
410
+ reapLiveIfIdle();
411
+ return;
412
+ }
413
+ }
278
414
  function screenrecordArgs() {
279
415
  const a = ["exec-out", "screenrecord", "--output-format=h264", "--size", SC_SIZE, "--bit-rate", SC_BITRATE];
280
416
  if (scDisplay)
@@ -300,16 +436,32 @@ async function primeFrame(ffmpeg) {
300
436
  function pushFrame(L, frame) {
301
437
  const head = Buffer.from(`--${SC_BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`);
302
438
  const tail = Buffer.from("\r\n");
439
+ // Prune viewers whose socket died without firing req.on("close") — a half-open TCP
440
+ // connection (host sleep, network drop, killed browser) leaves the response object in
441
+ // the set but not writable. Without pruning, a dead viewer keeps the cast "watched"
442
+ // forever and relaunch respawns screenrecord on every self-exit. This frame loop is the
443
+ // heartbeat that detects the dead socket. Collect first, mutate the set after iterating.
444
+ let dead = null;
303
445
  for (const r of L.viewers) {
446
+ if (!viewerAlive(r)) {
447
+ (dead ??= []).push(r);
448
+ continue;
449
+ }
304
450
  try {
305
451
  r.write(head);
306
452
  r.write(frame);
307
453
  r.write(tail);
308
454
  }
309
455
  catch {
310
- // viewer disconnected; harmless
456
+ // write threw on a socket that looked alive; treat it as dead too
457
+ (dead ??= []).push(r);
311
458
  }
312
459
  }
460
+ if (dead) {
461
+ for (const r of dead)
462
+ L.viewers.delete(r);
463
+ afterViewerRemoved();
464
+ }
313
465
  if (L.recording) {
314
466
  try {
315
467
  L.recording.proc.stdin?.write(frame);
@@ -358,6 +510,7 @@ function reapCastProcesses(L) {
358
510
  clearInterval(L.keepalive);
359
511
  L.keepalive = null;
360
512
  }
513
+ clearOrphanGrace(L);
361
514
  L.lastFrame = null;
362
515
  try {
363
516
  L.sr.kill("SIGKILL");
@@ -383,10 +536,14 @@ export function startLive(fps) {
383
536
  const ffmpeg = resolveFfmpeg();
384
537
  if (!ffmpeg)
385
538
  return null;
539
+ // Clamp once at the boundary: fps comes from a client-controlled ?fps= param, and it is used
540
+ // both in the live filter below and (via scLive.fps) by the recording. A negative/fractional/
541
+ // huge value would break either pipeline. Store and use only the clamped value.
542
+ const safeFps = clampFps(fps);
386
543
  const sr = spawn(adb(), screenrecordArgs(), { stdio: ["ignore", "pipe", "ignore"] });
387
544
  const ff = spawn(ffmpeg, [
388
545
  "-hide_banner", "-loglevel", "error", "-f", "h264", "-probesize", "64k", "-i", "pipe:0",
389
- "-vf", `format=yuvj420p,fps=${fps || 20}`, "-q:v", "6", "-flush_packets", "1",
546
+ "-vf", `format=yuvj420p,fps=${safeFps}`, "-q:v", "6", "-flush_packets", "1",
390
547
  "-f", "image2pipe", "-c:v", "mjpeg", "pipe:1",
391
548
  ], { stdio: ["pipe", "pipe", "ignore"] });
392
549
  sr.stdout?.pipe(ff.stdin);
@@ -397,8 +554,9 @@ export function startLive(fps) {
397
554
  lastFrame: null,
398
555
  lastFrameTs: 0,
399
556
  recording: null,
400
- fps: fps || 20,
557
+ fps: safeFps,
401
558
  keepalive: null,
559
+ orphanTimer: null,
402
560
  };
403
561
  let acc = Buffer.alloc(0);
404
562
  ff.stdout?.on("data", (chunk) => {
@@ -426,7 +584,21 @@ export function startLive(fps) {
426
584
  const onExit = () => {
427
585
  if (scLive === L) {
428
586
  scLive = null;
429
- if (L.viewers.size || L.recording)
587
+ // Relaunch ONLY while a viewer is actually watching (shouldRelaunch). A recording with
588
+ // no viewers must NOT relaunch: that is the orphaned-recording respawn loop (a tab closed
589
+ // mid-record before its stop POST landed kept screenrecord self-exiting and relaunching
590
+ // forever). With no viewers, cleanupLive finalizes the in-progress recording (the clip is
591
+ // saved, not lost) and reaps the pipeline. The orphan-grace timer normally reaps this
592
+ // within a few seconds, before screenrecord even self-exits.
593
+ //
594
+ // Edge (dead-viewer + fully static screen): if the only remaining viewer is a half-open
595
+ // dead socket, viewers.size is still 1 here, so we relaunch. The dead-viewer prune runs
596
+ // inside pushFrame, so it needs at least one frame from the fresh cast to fire. A newly
597
+ // spawned screenrecord emits a startup keyframe even on a static screen (see the module
598
+ // header's DOZE-panel note), so the fresh cast produces a frame, pushFrame prunes the dead
599
+ // viewer, and afterViewerRemoved reaps. Worst case is a single bounded relaunch cycle, not
600
+ // an unbounded loop.
601
+ if (shouldRelaunch(L.viewers.size))
430
602
  relaunch(L);
431
603
  else
432
604
  cleanupLive(L);
@@ -455,13 +627,16 @@ export function startLive(fps) {
455
627
  }
456
628
  /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
457
629
  export function addViewer(L, res) {
630
+ // A viewer returned; if the orphan-grace timer was counting down (last viewer had left
631
+ // while a recording ran), cancel it — the cast is watched again.
632
+ clearOrphanGrace(L);
458
633
  L.viewers.add(res);
459
634
  }
460
635
  export function removeViewer(res) {
461
636
  if (!scLive)
462
637
  return;
463
638
  scLive.viewers.delete(res);
464
- reapLiveIfIdle();
639
+ afterViewerRemoved();
465
640
  }
466
641
  /**
467
642
  * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
@@ -521,7 +696,20 @@ export function startRecording() {
521
696
  }
522
697
  const safe = new Date().toISOString().replace(/[:.]/g, "-");
523
698
  const out = path.join(SC_TMP, `radar_screen_${safe}.mp4`);
524
- const rec = spawn(ffmpeg, ["-hide_banner", "-loglevel", "error", "-y", "-f", "image2pipe", "-r", String(scLive.fps), "-c:v", "mjpeg", "-i", "pipe:0", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", out], { stdio: ["pipe", "ignore", "ignore"] });
699
+ // Time the recording by real frame arrival, not a fixed input rate (the arg shape + the
700
+ // reasons it must be exactly this are in recordingArgs). The old args stamped every frame
701
+ // at a fixed 1/fps, so a clip recorded over N real seconds played back as frames/fps
702
+ // seconds — slow motion by the real-fps/target ratio.
703
+ const rec = spawn(ffmpeg, recordingArgs(scLive.fps, out), { stdio: ["pipe", "ignore", "ignore"] });
704
+ // A ChildProcess with no 'error' listener throws an unhandled 'error' (crashing the process)
705
+ // if the spawn itself fails — e.g. ffmpeg was removed between resolveFfmpeg() and here, or an
706
+ // EAGAIN under load. resolveFfmpeg() just succeeded so this is unlikely, but a recording is a
707
+ // best-effort debug convenience and must never take the server down. Swallow it; the frame
708
+ // tee in pushFrame already tolerates a dead stdin, and the user simply gets no clip.
709
+ rec.on("error", () => {
710
+ if (scLive?.recording?.proc === rec)
711
+ scLive.recording = null;
712
+ });
525
713
  scLive.recording = { proc: rec, path: out };
526
714
  return out;
527
715
  }
@@ -530,6 +718,7 @@ export function stopRecording(L = scLive) {
530
718
  return Promise.resolve(null);
531
719
  const r = L.recording;
532
720
  L.recording = null;
721
+ clearOrphanGrace(L);
533
722
  return new Promise((resolve) => {
534
723
  let done = false;
535
724
  const fin = () => {
@@ -762,6 +762,13 @@ function handleScreen(req, res) {
762
762
  return;
763
763
  }
764
764
  const fps = Number(new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("fps")) || 20;
765
+ // Register the disconnect handler BEFORE the await. A client that aborts during
766
+ // detectDisplay() would otherwise become a phantom viewer whose close never fires, and
767
+ // on a static screen (no frames flowing) the frame-loop dead-viewer prune cannot catch
768
+ // it either — so it would hold the cast and respawn screenrecord. removeViewer is safe
769
+ // to call whether or not the response ever became a viewer (delete on an absent member
770
+ // is a no-op), so wiring it early costs nothing when the stream fails to start.
771
+ req.on("close", () => removeViewer(res));
765
772
  void (async () => {
766
773
  await detectDisplay();
767
774
  const L = startLive(fps);
@@ -769,13 +776,20 @@ function handleScreen(req, res) {
769
776
  sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
770
777
  return;
771
778
  }
779
+ // The client may have aborted during the await above (its close handler, wired before
780
+ // the await, already ran). Do not resurrect a dead socket as a viewer — that would be
781
+ // the very phantom viewer this reordering prevents. Reap in case startLive spun a cast
782
+ // up for a viewer that is already gone.
783
+ if (res.writableEnded || res.destroyed) {
784
+ reapLiveIfIdle();
785
+ return;
786
+ }
772
787
  res.writeHead(200, {
773
788
  "Content-Type": `multipart/x-mixed-replace; boundary=${SCREEN_BOUNDARY}`,
774
789
  "Cache-Control": "no-cache",
775
790
  Connection: "keep-alive",
776
791
  });
777
792
  addViewer(L, res);
778
- req.on("close", () => removeViewer(res));
779
793
  void primeIfNeeded();
780
794
  })();
781
795
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/index.js",