@slack/radar-mcp 1.4.0 → 1.5.0

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.
@@ -6,10 +6,36 @@ import { spawn } from "child_process";
6
6
  import { fileURLToPath } from "url";
7
7
  import { AndroidTransport, resolveAdbPath } from "../shared/android.js";
8
8
  import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js";
9
+ import { cleanupPulledDatabases, initializeDatabasePath, listDatabases, pullDatabase, pulledSize, queryDatabase, resetDbState, } from "../shared/db.js";
10
+ import { LogSession } from "./log-session.js";
9
11
  import { BLANK_SPEC, coerceSpec, validateSpec } from "./spec.js";
12
+ import { screenCapabilities, grantScreenConsent, hasScreenConsent, detectDisplay, displayNote, captureScreenshot, startLive, addViewer, removeViewer, primeIfNeeded, startRecording, stopRecording, recordingPath, resetScreenState, registerScreenCleanup, resolveFfmpeg, ffmpegInstallHint, SCREEN_BOUNDARY, } from "../shared/screen.js";
10
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
14
  const WEB_PORT = Number(process.env.SLACK_RADAR_WEB_PORT ?? WEB_PORT_DEFAULT);
12
15
  const INDEX_PATH = path.join(__dirname, "public", "index.html");
16
+ // Backpressure ceiling for an SSE connection's unwritten log frames. If a
17
+ // client's socket buffer is already this deep (slow/backgrounded tab), new log
18
+ // frames are dropped for that connection rather than queued without bound. ~4 MB
19
+ // is far above a healthy localhost client's transient buffer but well under a
20
+ // memory problem; a dropped frame is recovered on the client's next reconnect
21
+ // replay. Only the log fan-out is bounded this way — the device proxy stream is
22
+ // already paced by the upstream socket.
23
+ const MAX_SSE_WRITABLE_BYTES = 4 * 1024 * 1024;
24
+ /**
25
+ * Decide whether to drop a log frame under backpressure. LIVE frames are dropped
26
+ * when the connection's unwritten buffer is already past the cap (an unbounded
27
+ * queue to a slow/backgrounded tab). REPLAY frames are NEVER dropped: replay is a
28
+ * bounded one-time send of the whole ring and IS the connection's purpose; it runs
29
+ * synchronously so writableLength climbs monotonically, and dropping past the cap
30
+ * would silently cut the newest history and reintroduce the reconnect log-loss
31
+ * this feature fixes. Exported pure so the replay-exemption is unit-testable
32
+ * without a socket. `cap` defaults to the live ceiling.
33
+ */
34
+ export function shouldDropLogFrame(replay, writableLength, cap = MAX_SSE_WRITABLE_BYTES) {
35
+ if (replay)
36
+ return false;
37
+ return writableLength > cap;
38
+ }
13
39
  // The active dashboard spec is session-scoped runtime state, NOT source. It lives
14
40
  // under the user's home data dir (never inside the repo / published tarball) so it
15
41
  // cannot be accidentally committed. The path is scoped by port so two dashboards
@@ -19,6 +45,26 @@ const INDEX_PATH = path.join(__dirname, "public", "index.html");
19
45
  const SPEC_DIR = path.join(os.homedir(), ".slack-radar");
20
46
  const SPEC_PATH = path.join(SPEC_DIR, `web-spec-${WEB_PORT}.json`);
21
47
  const transport = new AndroidTransport();
48
+ // One session-long logcat capture shared across every dashboard stream
49
+ // connection. The device cannot replay logcat history to us, so the dashboard
50
+ // keeps its own bounded session ring here and replays it to each newly opened
51
+ // stream — this is what makes a tab switch (which reopens /stream) show the full
52
+ // log history instead of only lines that arrive after the reconnect, matching
53
+ // how Android Studio retains logcat for the whole session. Spawn + parse are
54
+ // injected so the capture logic is unit-testable without a real `adb logcat`.
55
+ const logSession = new LogSession(() => spawn(resolveAdbPath() ?? "adb", ["logcat", "-v", "threadtime", "-T", "1"], {
56
+ stdio: ["ignore", "pipe", "ignore"],
57
+ }), (line) => parseLogcat(line));
58
+ // Kill the persistent logcat child on process exit so it does not orphan when the
59
+ // dashboard is spawned and later terminated by the open_radar_dashboard MCP tool.
60
+ logSession.registerCleanup();
61
+ // By design the capture is NOT stopped when the last log stream disconnects (e.g.
62
+ // switching to the Network tab): it keeps filling the session ring so returning to
63
+ // a log tab — even much later — still shows the full history, the way Android
64
+ // Studio retains logcat for the whole session. The cost is a logcat reader running
65
+ // while no log tab is open; memory stays bounded by the ring cap and by the
66
+ // per-connection backpressure guard in handleStream (a stalled client drops frames
67
+ // rather than buffering without limit).
22
68
  function writeSpec(spec) {
23
69
  try {
24
70
  fs.mkdirSync(SPEC_DIR, { recursive: true });
@@ -59,7 +105,17 @@ function initialSpec() {
59
105
  }
60
106
  }
61
107
  writeSpec(initialSpec());
108
+ // Initialize the database path with the web server port, so concurrent dashboards
109
+ // on different ports do not collide in tmpdir.
110
+ initializeDatabasePath(WEB_PORT);
111
+ // Pulled DB copies are plaintext message stores; never let them linger across runs.
112
+ // Clean on start, and on the process signals the bin wires for shutdown.
113
+ cleanupPulledDatabases();
114
+ for (const sig of ["SIGINT", "SIGTERM", "exit"]) {
115
+ process.once(sig, cleanupPulledDatabases);
116
+ }
62
117
  export function createServer() {
118
+ registerScreenCleanup(); // tear down any live screen cast on process exit/signals
63
119
  const server = http.createServer((req, res) => {
64
120
  const url = req.url ?? "";
65
121
  if (url === "/" || url === "/index.html") {
@@ -94,6 +150,18 @@ export function createServer() {
94
150
  handleDetail(req, res);
95
151
  return;
96
152
  }
153
+ if (url.startsWith("/dbs") && req.method === "GET") {
154
+ handleDbList(req, res);
155
+ return;
156
+ }
157
+ if (url.startsWith("/dbtables") && req.method === "GET") {
158
+ handleDbTables(req, res);
159
+ return;
160
+ }
161
+ if (url.startsWith("/dbquery") && req.method === "GET") {
162
+ handleDbQuery(req, res);
163
+ return;
164
+ }
97
165
  if (url === "/_control/profiles" && req.method === "GET") {
98
166
  listProfiles(res);
99
167
  return;
@@ -104,6 +172,10 @@ export function createServer() {
104
172
  activateRadar(res, userId);
105
173
  return;
106
174
  }
175
+ if (url.startsWith("/screen/")) {
176
+ handleScreen(req, res);
177
+ return;
178
+ }
107
179
  if (url.startsWith("/api/")) {
108
180
  proxyToRadar(req, res);
109
181
  return;
@@ -177,6 +249,16 @@ async function handleSetSpec(req, res) {
177
249
  * forward/proxy base as the rest of the server.
178
250
  */
179
251
  function handleHealth(res) {
252
+ // A probe can fire BOTH an "end" and an "error"/"timeout" (partial response then socket
253
+ // drop), so guard the response to exactly one write. Without this the second sendJson
254
+ // does a second writeHead -> ERR_HTTP_HEADERS_SENT -> uncaught -> the whole server dies.
255
+ let replied = false;
256
+ const reply = (device) => {
257
+ if (replied)
258
+ return;
259
+ replied = true;
260
+ sendJson(res, 200, { device, screen: screenCapabilities() });
261
+ };
180
262
  const probe = http.request({
181
263
  host: RADAR_HOST,
182
264
  port: RADAR_PORT,
@@ -185,12 +267,13 @@ function handleHealth(res) {
185
267
  timeout: 2500,
186
268
  }, (up) => {
187
269
  up.resume();
188
- up.on("end", () => sendJson(res, 200, { device: up.statusCode === 200 }));
270
+ up.on("end", () => reply(up.statusCode === 200));
271
+ up.on("error", () => reply(false));
189
272
  });
190
- probe.on("error", () => sendJson(res, 200, { device: false }));
273
+ probe.on("error", () => reply(false));
191
274
  probe.on("timeout", () => {
192
275
  probe.destroy();
193
- sendJson(res, 200, { device: false });
276
+ reply(false);
194
277
  });
195
278
  probe.end();
196
279
  }
@@ -202,6 +285,10 @@ function handleHealth(res) {
202
285
  */
203
286
  function handleEnable(res) {
204
287
  transport.resetState();
288
+ // A reconnect may be a different device / re-armed profile, so drop the cached DB user
289
+ // set + sqlite path; the next /dbs re-detects the profiles fresh.
290
+ resetDbState();
291
+ resetScreenState(); // re-detect the active display + clear any stale cast on re-arm
205
292
  const forwarded = transport.ensureForward();
206
293
  if (!forwarded) {
207
294
  const reason = transport.getLastForwardError?.() ?? null;
@@ -221,7 +308,15 @@ function handleEnable(res) {
221
308
  });
222
309
  return;
223
310
  }
224
- // Verify by pinging the device the same way /health does.
311
+ // Verify by pinging the device the same way /health does. Reply exactly once (an "end"
312
+ // and an "error"/"timeout" can both fire) to avoid a double writeHead crashing the server.
313
+ let replied = false;
314
+ const reply = (enabled) => {
315
+ if (replied)
316
+ return;
317
+ replied = true;
318
+ sendJson(res, 200, { enabled });
319
+ };
225
320
  const probe = http.request({
226
321
  host: RADAR_HOST,
227
322
  port: RADAR_PORT,
@@ -230,12 +325,13 @@ function handleEnable(res) {
230
325
  timeout: 2500,
231
326
  }, (up) => {
232
327
  up.resume();
233
- up.on("end", () => sendJson(res, 200, { enabled: up.statusCode === 200 }));
328
+ up.on("end", () => reply(up.statusCode === 200));
329
+ up.on("error", () => reply(false));
234
330
  });
235
- probe.on("error", () => sendJson(res, 200, { enabled: false }));
331
+ probe.on("error", () => reply(false));
236
332
  probe.on("timeout", () => {
237
333
  probe.destroy();
238
- sendJson(res, 200, { enabled: false });
334
+ reply(false);
239
335
  });
240
336
  probe.end();
241
337
  }
@@ -354,6 +450,229 @@ function handleDetail(req, res) {
354
450
  });
355
451
  upstream.end();
356
452
  }
453
+ // --- On-device SQLite browser (source: "db") ---
454
+ //
455
+ // These delegate to shared/db.ts (run-as + host sqlite3, read-only). Each computes its
456
+ // payload+status BEFORE writing headers so a throw still yields a clean JSON error rather
457
+ // than a half-written response. Errors come back as 200 {error} for the list/tables views
458
+ // (so the UI renders the message in-place) and 400 {error} for a rejected query.
459
+ // The Android user whose store the DB browser targets. Prefer an explicit ?user= (the UI's
460
+ // profile switcher, when a dual-profile device exists), else the profile the dashboard is
461
+ // bound to. Threaded through every db call so list / pull / query never disagree on profile.
462
+ function dbUser(req) {
463
+ const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
464
+ const explicit = u.searchParams.get("user");
465
+ if (explicit !== null)
466
+ return explicit;
467
+ // No explicit pick: default to the profile the debug build is actually running as
468
+ // (resolveActiveUser prefers the non-zero/secondary profile Radar binds to), not user 0.
469
+ try {
470
+ return transport.resolveActiveUser();
471
+ }
472
+ catch {
473
+ return undefined; // let db.ts fall back to its first available user
474
+ }
475
+ }
476
+ function handleDbList(req, res) {
477
+ try {
478
+ sendJson(res, 200, listDatabases(dbUser(req)));
479
+ }
480
+ catch (e) {
481
+ sendJson(res, 200, { error: e.message, names: [], availableUsers: [] });
482
+ }
483
+ }
484
+ async function handleDbTables(req, res) {
485
+ const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
486
+ const db = u.searchParams.get("db") ?? "";
487
+ const user = dbUser(req);
488
+ const force = u.searchParams.get("refresh") === "1";
489
+ let payload;
490
+ let status;
491
+ try {
492
+ await pullDatabase(db, user, force);
493
+ const tableRows = (await queryDatabase(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", user));
494
+ // Row counts: awaited serially against the already-pulled LOCAL copy (each is fast and
495
+ // keeps the event loop free between calls; 91 parallel sqlite procs would spike).
496
+ const tables = [];
497
+ for (const t of tableRows) {
498
+ let rows = "?";
499
+ try {
500
+ rows = (await queryDatabase(db, `SELECT COUNT(*) c FROM "${t.name}"`, user))[0].c;
501
+ }
502
+ catch {
503
+ // a view or virtual table may not count; leave "?"
504
+ }
505
+ tables.push({ name: t.name, rows });
506
+ }
507
+ payload = { db, sizeBytes: pulledSize(db, user), tables };
508
+ status = 200;
509
+ }
510
+ catch (e) {
511
+ payload = { error: e.message };
512
+ status = 200; // surfaced in the tables pane, not a transport error
513
+ }
514
+ sendJson(res, status, payload);
515
+ }
516
+ async function handleDbQuery(req, res) {
517
+ const u = new URL(req.url ?? "", `http://localhost:${WEB_PORT}`);
518
+ const db = u.searchParams.get("db") ?? "";
519
+ const sql = u.searchParams.get("sql") ?? "";
520
+ let payload;
521
+ let status;
522
+ try {
523
+ payload = { rows: await queryDatabase(db, sql, dbUser(req)) };
524
+ status = 200;
525
+ }
526
+ catch (e) {
527
+ payload = { error: String(e.message).split("\n")[0] };
528
+ status = 400;
529
+ }
530
+ sendJson(res, status, payload);
531
+ }
532
+ /**
533
+ * Screen overlay routes. The live screen shows real DMs and message content, so the
534
+ * stream and record routes are gated behind an explicit per-process consent (granted
535
+ * by POST /screen/consent from the in-UI consent panel). The gate is enforced HERE,
536
+ * server-side, not just in the browser, so a direct request cannot bypass it.
537
+ *
538
+ * /screen/caps capabilities (ffmpeg/scrcpy present, install hint, consent state)
539
+ * /screen/consent POST: grant consent for this server process
540
+ * /screen/shot full-resolution PNG screenshot (pure screencap; works without ffmpeg)
541
+ * /screen/stream multipart/x-mixed-replace MJPEG live cast (needs ffmpeg + consent)
542
+ * /screen/record/start|stop record the live cast to mp4 (needs ffmpeg + consent)
543
+ * /screen/download download a finished recording
544
+ * /screen/redetect re-detect the active display
545
+ */
546
+ function handleScreen(req, res) {
547
+ const url = req.url ?? "";
548
+ // Capabilities + consent state. Always available; drives the overlay's degrade UI.
549
+ if (url.startsWith("/screen/caps")) {
550
+ sendJson(res, 200, { ...screenCapabilities(), consent: hasScreenConsent() });
551
+ return;
552
+ }
553
+ // Grant consent for this process. Required before any pixels are mirrored.
554
+ if (url === "/screen/consent" && req.method === "POST") {
555
+ grantScreenConsent();
556
+ sendJson(res, 200, { consent: true });
557
+ return;
558
+ }
559
+ // Screenshot: pure screencap, no ffmpeg. Still consent-gated — it is the live screen.
560
+ if (url.startsWith("/screen/shot")) {
561
+ if (!hasScreenConsent()) {
562
+ sendJson(res, 403, { error: "screen consent required" });
563
+ return;
564
+ }
565
+ void (async () => {
566
+ await detectDisplay();
567
+ const png = await captureScreenshot();
568
+ if (!png) {
569
+ sendJson(res, 502, { error: "screencap failed; is the device awake and connected?" });
570
+ return;
571
+ }
572
+ res.writeHead(200, {
573
+ "Content-Type": "image/png",
574
+ "Content-Disposition": `attachment; filename="radar-screen-${Date.now()}.png"`,
575
+ });
576
+ res.end(png);
577
+ })();
578
+ return;
579
+ }
580
+ // Live MJPEG stream. Needs consent AND ffmpeg.
581
+ if (url.startsWith("/screen/stream")) {
582
+ if (!hasScreenConsent()) {
583
+ sendJson(res, 403, { error: "screen consent required" });
584
+ return;
585
+ }
586
+ if (!resolveFfmpeg()) {
587
+ sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
588
+ return;
589
+ }
590
+ const fps = Number(new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("fps")) || 20;
591
+ void (async () => {
592
+ await detectDisplay();
593
+ const L = startLive(fps);
594
+ if (!L) {
595
+ sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
596
+ return;
597
+ }
598
+ res.writeHead(200, {
599
+ "Content-Type": `multipart/x-mixed-replace; boundary=${SCREEN_BOUNDARY}`,
600
+ "Cache-Control": "no-cache",
601
+ Connection: "keep-alive",
602
+ });
603
+ addViewer(L, res);
604
+ req.on("close", () => removeViewer(res));
605
+ void primeIfNeeded();
606
+ })();
607
+ return;
608
+ }
609
+ if (url === "/screen/record/start" && req.method === "POST") {
610
+ if (!hasScreenConsent()) {
611
+ sendJson(res, 403, { error: "screen consent required" });
612
+ return;
613
+ }
614
+ void (async () => {
615
+ await detectDisplay();
616
+ if (!startLive(20)) {
617
+ sendJson(res, 501, { error: "ffmpeg not installed", installHint: ffmpegInstallHint() });
618
+ return;
619
+ }
620
+ const p = startRecording();
621
+ sendJson(res, 200, { ok: p !== null, file: p ? path.basename(p) : null });
622
+ })();
623
+ return;
624
+ }
625
+ if (url === "/screen/record/stop" && req.method === "POST") {
626
+ void (async () => {
627
+ const p = await stopRecording();
628
+ const file = p ? path.basename(p) : null;
629
+ let sizeBytes = 0;
630
+ if (p) {
631
+ try {
632
+ sizeBytes = fs.statSync(p).size;
633
+ }
634
+ catch {
635
+ sizeBytes = 0;
636
+ }
637
+ }
638
+ sendJson(res, 200, {
639
+ ok: true,
640
+ file,
641
+ sizeBytes,
642
+ download: file ? "/screen/download?f=" + encodeURIComponent(file) : null,
643
+ });
644
+ })();
645
+ return;
646
+ }
647
+ if (url.startsWith("/screen/download")) {
648
+ // A recording is device-screen content, so gate download on consent too — a fresh
649
+ // (unconsented) process must not serve a prior session's screen recording.
650
+ if (!hasScreenConsent()) {
651
+ sendJson(res, 403, { error: "screen consent required" });
652
+ return;
653
+ }
654
+ const f = new URL(url, `http://localhost:${WEB_PORT}`).searchParams.get("f") ?? "";
655
+ const p = recordingPath(f);
656
+ if (!p) {
657
+ res.writeHead(404);
658
+ res.end("Not found");
659
+ return;
660
+ }
661
+ res.writeHead(200, {
662
+ "Content-Type": "video/mp4",
663
+ "Content-Disposition": `attachment; filename="${path.basename(p)}"`,
664
+ });
665
+ fs.createReadStream(p).pipe(res);
666
+ return;
667
+ }
668
+ if (url === "/screen/redetect") {
669
+ resetScreenState();
670
+ void detectDisplay().then(() => sendJson(res, 200, { display: displayNote() }));
671
+ return;
672
+ }
673
+ res.writeHead(404);
674
+ res.end("Not found");
675
+ }
357
676
  // Logcat line parsing for the host-side log merge. Format:
358
677
  // "MM-DD HH:MM:SS.mmm PID TID L Tag: message"
359
678
  const LOG_LINE_RE = /^(\d\d-\d\d \d\d:\d\d:\d\d\.\d+)\s+(\d+)\s+(\d+)\s+([VDIWEF])\s+([^:]*?):\s?(.*)$/;
@@ -437,10 +756,13 @@ export function parseLogcat(line, resolvePkg = packageForPid) {
437
756
  * Every other retry it rebuilds the adb forward (the real cause of recurring
438
757
  * "unreachable"), reusing AndroidTransport rather than shelling adb directly.
439
758
  *
440
- * When the active spec wants logs (source "log" or "all"), it also spawns a
441
- * host-side `adb logcat -v threadtime -T 1` and merges parsed lines as
442
- * `type: "log"` frames. The adb binary is resolved via resolveAdbPath() so GUI
443
- * launchers without the user's PATH still work.
759
+ * When the active spec wants logs (source "log" or "all"), the handler subscribes
760
+ * to the shared session-long logSession: it is first replayed the entire retained
761
+ * ring buffer (so a tab switch shows full history), then receives live lines as
762
+ * they arrive. The logcat process itself is persistent and shared across all
763
+ * /stream clients, so tab switches never drop history AND the active tab keeps
764
+ * streaming live. The adb binary is resolved via resolveAdbPath() so GUI launchers
765
+ * without the user's PATH still work.
444
766
  */
445
767
  function handleStream(req, res) {
446
768
  res.writeHead(200, {
@@ -457,6 +779,24 @@ function handleStream(req, res) {
457
779
  lastState = state;
458
780
  res.write(`data: ${JSON.stringify({ type: "_status", event: { state, msg } })}\n\n`);
459
781
  };
782
+ // Keep the persistent logcat capture armed for the life of this connection.
783
+ // start() is idempotent, so it is safe to call both on initial setup and from
784
+ // the device-proxy reconnect path: when the device sleeps/unplugs, `adb logcat`
785
+ // exits and LogSession clears its child, and without a re-arm the live log feed
786
+ // would stay dead (until a tab switch) while network/rtm/clog self-heal via the
787
+ // proxy retry loop. Calling this on reconnect heals logs on the same path.
788
+ const spec = loadSpec();
789
+ const wantsLogs = spec.source === "log" || spec.source === "all";
790
+ const armLogcat = () => {
791
+ if (!wantsLogs || closed)
792
+ return;
793
+ const ok = logSession.start();
794
+ if (!ok) {
795
+ // Spawn failed (e.g. no adb on PATH). The capture is a named session-scoped
796
+ // thing now, so surface the reason rather than showing silent emptiness.
797
+ announce("waiting", "device logs unavailable (adb not found?)");
798
+ }
799
+ };
460
800
  let retries = 0;
461
801
  // One scheduled reconnect per attempt. up.on("end"), up.on("error"), and
462
802
  // upstream.on("error") can all fire for the same attempt; without this guard each
@@ -493,6 +833,10 @@ function handleStream(req, res) {
493
833
  method: "GET",
494
834
  }, (up) => {
495
835
  announce("connected", "device stream connected");
836
+ // Re-arm logs on the same path the device proxy heals on: if the device
837
+ // dropped and came back, `adb logcat` had exited; restart it (idempotent)
838
+ // so the live log feed resumes without needing a browser reconnect.
839
+ armLogcat();
496
840
  up.on("data", (c) => res.write(c));
497
841
  up.on("end", () => {
498
842
  announce("waiting", "device stream ended, retrying…");
@@ -514,53 +858,60 @@ function handleStream(req, res) {
514
858
  if (!closed)
515
859
  res.write(": hb\n\n");
516
860
  }, 15000);
517
- // Host-side logcat merge for log/all sources. -T 1 streams only NEW lines;
518
- // without it logcat replays the entire existing buffer on connect and floods
519
- // the browser.
520
- let logcat = null;
521
- const spec = loadSpec();
522
- const wantsLogs = spec.source === "log" || spec.source === "all";
523
- const adb = resolveAdbPath();
524
- if (wantsLogs && adb) {
525
- try {
526
- refreshPidMap(); // warm PID->package so the first log rows resolve their app
527
- logcat = spawn(adb, ["logcat", "-v", "threadtime", "-T", "1"], {
528
- stdio: ["ignore", "pipe", "ignore"],
529
- });
530
- let lineBuf = "";
531
- logcat.stdout?.on("data", (c) => {
532
- lineBuf += c.toString();
533
- let nl;
534
- while ((nl = lineBuf.indexOf("\n")) >= 0) {
535
- const line = lineBuf.slice(0, nl);
536
- lineBuf = lineBuf.slice(nl + 1);
537
- const ev = parseLogcat(line);
538
- if (ev && !closed) {
539
- res.write(`data: ${JSON.stringify({ type: "log", event: ev })}\n\n`);
540
- }
541
- }
542
- });
543
- logcat.on("error", () => { });
544
- }
545
- catch {
546
- logcat = null;
547
- }
861
+ // Host-side logcat merge for log/all sources. Subscribe to the shared,
862
+ // session-long logSession: it replays the full retained ring buffer to this
863
+ // connection first (so a tab switch shows complete history), then delivers
864
+ // live lines. Both replayed and live frames are written to THIS response, so
865
+ // the tab you are watching keeps streaming in real time. The logcat process
866
+ // is persistent and shared, so a tab switch only adds/removes a listener — it
867
+ // never tears down the capture or loses lines in a reconnect gap.
868
+ let unsubscribeLogs = null;
869
+ if (wantsLogs) {
870
+ refreshPidMap(); // warm PID->package so the first log rows resolve their app
871
+ armLogcat(); // idempotent start; surfaces a status frame if spawn fails
872
+ unsubscribeLogs = logSession.subscribe((ev, replay) => {
873
+ if (closed)
874
+ return;
875
+ // Backpressure guard: if this client is slow or its tab is backgrounded
876
+ // (an EventSource stays subscribed when backgrounded), res.write() queues
877
+ // unwritten bytes in Node's writable buffer without bound. The ring is
878
+ // bounded but that per-connection buffer is not, so a stalled consumer
879
+ // would grow host memory for the whole (now persistent) session. When the
880
+ // socket buffer is already deep, DROP this frame rather than buffer it —
881
+ // the client replays the full ring on its next reconnect and catches up.
882
+ //
883
+ // EXEMPT replay: the replay burst is a bounded one-time send of the whole
884
+ // ring (~10 MB worst case at the 20000-frame cap) and IS the connection's
885
+ // purpose. It runs synchronously oldest->newest, so the I/O phase cannot
886
+ // drain between writes and writableLength climbs monotonically; gating it
887
+ // on the 4 MB cap would silently drop the NEWEST history past ~4 MB and
888
+ // reintroduce the very "log count shrinks on reconnect" loss this feature
889
+ // fixes. The guard is only meant for the unbounded LIVE queue.
890
+ if (shouldDropLogFrame(replay, res.writableLength))
891
+ return;
892
+ // Tag replayed history with replay:true so the client counts it as history
893
+ // (live=false) and does NOT fire alerts on it. The client's own cache is
894
+ // capped (count + TTL) while the server ring is larger, so on a long
895
+ // session the client can evict frames the ring still holds; without this
896
+ // flag those replayed-on-reconnect frames would look new and re-fire
897
+ // `alert {at:N}` and re-count. The flag makes replay alert-safe regardless.
898
+ const frame = replay
899
+ ? { type: "log", event: ev, replay: true }
900
+ : { type: "log", event: ev };
901
+ res.write(`data: ${JSON.stringify(frame)}\n\n`);
902
+ });
548
903
  }
549
904
  req.on("close", () => {
550
905
  closed = true;
551
906
  clearInterval(heartbeat);
907
+ if (unsubscribeLogs)
908
+ unsubscribeLogs(); // detach listener; capture stays up
552
909
  try {
553
910
  upstream?.destroy();
554
911
  }
555
912
  catch {
556
913
  // already torn down
557
914
  }
558
- try {
559
- logcat?.kill();
560
- }
561
- catch {
562
- // already dead
563
- }
564
915
  });
565
916
  }
566
917
  function listProfiles(res) {
@@ -5,12 +5,17 @@
5
5
  * and the same validate/coerce pass runs on every push so a malformed spec surfaces
6
6
  * an error instead of rendering silent zeros.
7
7
  *
8
- * Scope note: this port covers the STREAMING sources only. The on-device SQLite
9
- * browser (`source: "db"`) from the original PoC is intentionally omitted here and
10
- * tracked as a separate change; `SOURCES` therefore does not include "db".
8
+ * Most sources are live STREAMS (network/rtm/clog/log/all). `db` is the odd one out:
9
+ * a read-only, point-in-time SQLite BROWSER over the on-device databases, with no viz /
10
+ * series / match (it has its own list -> tables -> query-grid view). The coerce and
11
+ * validate passes short-circuit for `db` accordingly.
11
12
  */
12
- /** Streaming sources the dashboard can read. "all" is the unified timeline. */
13
- export declare const SOURCES: readonly ["rtm", "network", "clog", "log", "all"];
13
+ /**
14
+ * Sources the dashboard can read. The first five are live streams; "all" is their unified
15
+ * timeline. "db" is a read-only on-device SQLite browser (a point-in-time snapshot, not a
16
+ * stream) and is handled as a distinct view.
17
+ */
18
+ export declare const SOURCES: readonly ["rtm", "network", "clog", "log", "all", "db"];
14
19
  export type Source = (typeof SOURCES)[number];
15
20
  /** Widgets a spec may compose. */
16
21
  export declare const VIZ: readonly ["counter", "rate", "sparkline", "feed", "inspector", "graph"];
@@ -62,6 +67,9 @@ export interface Spec {
62
67
  extract?: Extract;
63
68
  highlight?: Highlight;
64
69
  alert?: Alert;
70
+ db?: string;
71
+ table?: string;
72
+ sql?: string;
65
73
  [key: string]: unknown;
66
74
  }
67
75
  /** The empty dashboard. Rendered until a spec is pushed. */
package/dist/web/spec.js CHANGED
@@ -5,12 +5,17 @@
5
5
  * and the same validate/coerce pass runs on every push so a malformed spec surfaces
6
6
  * an error instead of rendering silent zeros.
7
7
  *
8
- * Scope note: this port covers the STREAMING sources only. The on-device SQLite
9
- * browser (`source: "db"`) from the original PoC is intentionally omitted here and
10
- * tracked as a separate change; `SOURCES` therefore does not include "db".
8
+ * Most sources are live STREAMS (network/rtm/clog/log/all). `db` is the odd one out:
9
+ * a read-only, point-in-time SQLite BROWSER over the on-device databases, with no viz /
10
+ * series / match (it has its own list -> tables -> query-grid view). The coerce and
11
+ * validate passes short-circuit for `db` accordingly.
11
12
  */
12
- /** Streaming sources the dashboard can read. "all" is the unified timeline. */
13
- export const SOURCES = ["rtm", "network", "clog", "log", "all"];
13
+ /**
14
+ * Sources the dashboard can read. The first five are live streams; "all" is their unified
15
+ * timeline. "db" is a read-only on-device SQLite browser (a point-in-time snapshot, not a
16
+ * stream) and is handled as a distinct view.
17
+ */
18
+ export const SOURCES = ["rtm", "network", "clog", "log", "all", "db"];
14
19
  /** Widgets a spec may compose. */
15
20
  export const VIZ = [
16
21
  "counter",
@@ -45,6 +50,9 @@ export function coerceSpec(input) {
45
50
  if (!isRecord(input))
46
51
  return BLANK_SPEC;
47
52
  const s = input;
53
+ // The DB browser has no match/viz/series to repair; leave its db/table/sql untouched.
54
+ if (s.source === "db")
55
+ return s;
48
56
  if (typeof s.match === "string") {
49
57
  // "all" mixes types whose summary fields differ, so a single field guess
50
58
  // would wrongly exclude other types; treat a bare-string match on "all"
@@ -90,6 +98,16 @@ export function validateSpec(s) {
90
98
  if (!spec.source || !SOURCES.includes(spec.source)) {
91
99
  return `source must be one of ${SOURCES.join("|")}`;
92
100
  }
101
+ // The DB browser is a distinct view: no viz/series/match. Only its optional deep-link
102
+ // fields are constrained (each must be a string when present).
103
+ if (spec.source === "db") {
104
+ for (const k of ["db", "table", "sql"]) {
105
+ if (spec[k] !== undefined && typeof spec[k] !== "string") {
106
+ return `${k} must be a string`;
107
+ }
108
+ }
109
+ return null;
110
+ }
93
111
  if (spec.match !== undefined && !isRecord(spec.match)) {
94
112
  return "match must be an object";
95
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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",