@slack/radar-mcp 1.7.0 → 1.8.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.
package/dist/mcp/index.js CHANGED
@@ -17,6 +17,7 @@ import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./d
17
17
  import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
18
18
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
19
19
  import { buildApiPath } from "./api-paths.js";
20
+ import { interpretRadarResponse } from "./radar-response.js";
20
21
  // --- Subcommand + bin-name dispatch ---
21
22
  //
22
23
  // One entrypoint, two front doors, chosen so each invocation does the obvious thing:
@@ -200,6 +201,7 @@ async function runMcpServer() {
200
201
  process.once(sig, cleanupPulledDatabases);
201
202
  }
202
203
  let sessionEnabled = false;
204
+ // --- HTTP requests to the on-device radar server ---
203
205
  // Host-side ceiling on a proxied device response. radarRequest forwards the device body straight
204
206
  // into the model context, so an unbounded response (large trace/clog payloads) could flood it.
205
207
  // When the cap trips we stop reading, tear the socket down, and return a truncation marker
@@ -249,12 +251,7 @@ async function runMcpServer() {
249
251
  data += chunk;
250
252
  });
251
253
  res.on("end", () => {
252
- try {
253
- done(JSON.parse(data));
254
- }
255
- catch {
256
- done({ raw: data });
257
- }
254
+ done(interpretRadarResponse(res.statusCode ?? 0, data));
258
255
  });
259
256
  });
260
257
  req.on("error", fail);
@@ -552,7 +549,7 @@ async function runMcpServer() {
552
549
  }
553
550
  }
554
551
  // --- MCP Server ---
555
- const server = new Server({ name: "slack-radar", version: "1.7.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
552
+ const server = new Server({ name: "slack-radar", version: "1.8.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
556
553
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
557
554
  tools: TOOL_DEFINITIONS,
558
555
  }));
@@ -0,0 +1,21 @@
1
+ export interface RadarResponse {
2
+ error?: string;
3
+ status?: number;
4
+ stream_connected?: boolean;
5
+ local_network_buffer?: number;
6
+ local_rtm_buffer?: number;
7
+ timeout_minutes?: number;
8
+ raw?: string;
9
+ [key: string]: unknown;
10
+ }
11
+ /**
12
+ * Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
13
+ * when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
14
+ * {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
15
+ * whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
16
+ * an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
17
+ * In that case synthesize a clear error naming the status so the model reports something useful
18
+ * (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
19
+ * body keeps the existing {raw} behavior, a real if unusual success payload.
20
+ */
21
+ export declare function interpretRadarResponse(status: number, body: string): RadarResponse;
@@ -0,0 +1,38 @@
1
+ // The shape a proxied device call resolves to, and how a raw (status, body) becomes one. Split
2
+ // out of index.ts so it is unit-testable: index.ts connects stdio at module load, so importing it
3
+ // from a test would hang.
4
+ /**
5
+ * Turn a device HTTP (status, body) into the RadarResponse the tool layer returns. Parse JSON
6
+ * when the body is JSON: this preserves the device's own error bodies (e.g. an in-app 404 sends
7
+ * {"error":"Not Found"}) and every happy-path payload unchanged. The added guard is for a 4xx/5xx
8
+ * whose body is NOT JSON (empty, HTML, a bare string): a build that predates a tool's route, or
9
+ * an upstream proxy error, would otherwise surface as a confusing {raw:""} the caller cannot read.
10
+ * In that case synthesize a clear error naming the status so the model reports something useful
11
+ * (an older debug build can predate a newer tool) instead of an empty blob. A 2xx with a non-JSON
12
+ * body keeps the existing {raw} behavior, a real if unusual success payload.
13
+ */
14
+ export function interpretRadarResponse(status, body) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(body);
18
+ }
19
+ catch {
20
+ if (status >= 400) {
21
+ const bodyPrefix = body.substring(0, 200).trim();
22
+ const bodyDetail = bodyPrefix ? ` Body: ${bodyPrefix}` : "";
23
+ return {
24
+ error: `Device returned HTTP ${status} with a non-JSON body. The route may not exist on this build (an older debug build can predate a newer tool), or an upstream error occurred.${bodyDetail}`,
25
+ status,
26
+ };
27
+ }
28
+ return { raw: body };
29
+ }
30
+ // Device routes return JSON OBJECTS. A bare null/number/array/string is valid JSON but not a
31
+ // RadarResponse, and a downstream `result.error` read on a non-object (esp. null) would throw.
32
+ // Keep it as a raw payload instead of casting a lie. (The old code cast straight to
33
+ // RadarResponse and carried this hole; guarding here closes it.)
34
+ if (typeof parsed !== "object" || parsed === null) {
35
+ return { raw: body };
36
+ }
37
+ return parsed;
38
+ }
@@ -13,6 +13,7 @@ export interface SnapshotResult {
13
13
  network: number;
14
14
  rtm: number;
15
15
  clogs: number;
16
+ traces: number;
16
17
  log_lines: number;
17
18
  log_lines_truncated: boolean;
18
19
  screenshot: boolean;
@@ -21,6 +22,7 @@ export interface SnapshotResult {
21
22
  network: boolean;
22
23
  rtm: boolean;
23
24
  clogs: boolean;
25
+ traces: boolean;
24
26
  };
25
27
  errors: string[];
26
28
  privacy_notice: string;
@@ -35,7 +37,7 @@ export declare function buildSnapshotDir(now?: number, root?: string): string;
35
37
  * intentionally larger than point-query defaults — snapshots are bulk
36
38
  * exports and the device-side ring buffers cap their own responses.
37
39
  */
38
- export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
40
+ export declare function buildBufferPath(kind: "network" | "rtm" | "clogs" | "traces"): string;
39
41
  export interface LogTailResult {
40
42
  text: string;
41
43
  truncated: boolean;
@@ -60,4 +62,4 @@ export declare function readRecentLogTail(requestedLimit?: number): LogTailResul
60
62
  * snapshot still lands on disk with whatever succeeded; failures are
61
63
  * collected in `result.errors` so the model can tell the user.
62
64
  */
63
- export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
65
+ export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs" | "traces") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
@@ -10,7 +10,7 @@ import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
10
10
  * tool outputs.
11
11
  *
12
12
  * Snapshot contents:
13
- * - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
13
+ * - `bundle.json` — device-side buffers (net + RTM + clogs + traces) plus metadata
14
14
  * - `screenshot.png` — current screen (optional)
15
15
  * - `logcat.tail.log` — last N lines of the active capture file (optional)
16
16
  *
@@ -33,19 +33,19 @@ const BUNDLE_README = `Slack Radar snapshot
33
33
  This directory is a point-in-time bundle of Radar state.
34
34
 
35
35
  Files:
36
- - bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
36
+ - bundle.json: device-side network/RTM/clog/trace buffers, counts, and metadata.
37
37
  - logcat.tail.log (optional): last N lines of the on-device logcat capture.
38
38
  - screenshot.png (optional): current screen.
39
39
 
40
40
  Privacy: bundle.json contains raw API request/response bodies (auth headers,
41
- tokens), RTM message content, user IDs, channel IDs, and clog payloads.
42
- Review before sharing externally. This bundle lives on your local disk only;
43
- nothing is uploaded anywhere by Radar.
41
+ tokens), RTM message content, user IDs, channel IDs, clog payloads, and
42
+ performance trace spans. Review before sharing externally. This bundle lives
43
+ on your local disk only; nothing is uploaded anywhere by Radar.
44
44
 
45
45
  To inspect: cat bundle.json | jq .
46
46
  To share: zip -r snapshot.zip . then attach the zip.
47
47
  `;
48
- const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally.";
48
+ const PRIVACY_NOTICE = "bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally.";
49
49
  /**
50
50
  * Resolve the output directory for a snapshot. Exported for tests.
51
51
  */
@@ -137,7 +137,8 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
137
137
  let network = null;
138
138
  let rtm = null;
139
139
  let clogs = null;
140
- const fetched = { network: false, rtm: false, clogs: false };
140
+ let traces = null;
141
+ const fetched = { network: false, rtm: false, clogs: false, traces: false };
141
142
  // Device-side buffers. Failures are individually caught so one failing
142
143
  // buffer does not nuke the whole snapshot. `fetched` distinguishes
143
144
  // "empty because the buffer was empty" from "null because the fetch
@@ -163,6 +164,17 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
163
164
  catch (e) {
164
165
  errors.push(`clog buffer: ${e.message}`);
165
166
  }
167
+ // Traces capture from app start (like clogs), so they are worth
168
+ // bundling. An older debug build without the /api/traces route fails
169
+ // this one fetch and it is recorded in errors[] like any other buffer;
170
+ // the rest of the snapshot still lands.
171
+ try {
172
+ traces = await fetchBuffer("traces");
173
+ fetched.traces = true;
174
+ }
175
+ catch (e) {
176
+ errors.push(`trace buffer: ${e.message}`);
177
+ }
166
178
  // Logcat tail.
167
179
  let logTail = { text: "", truncated: false };
168
180
  if (includeLogs) {
@@ -221,6 +233,7 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
221
233
  network: countItems(network),
222
234
  rtm: countItems(rtm),
223
235
  clogs: countItems(clogs),
236
+ traces: countItems(traces),
224
237
  log_lines: logTail.text
225
238
  ? logTail.text.split("\n").filter(Boolean).length
226
239
  : 0,
@@ -228,12 +241,12 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
228
241
  screenshot: screenshotCaptured,
229
242
  };
230
243
  const bundle = {
231
- version: 1,
244
+ version: 2,
232
245
  timestamp: now,
233
246
  platform: device.platform,
234
247
  active_user_id: device.getActiveUserId(),
235
248
  capture_file: getCaptureFilePath(),
236
- buffers: { network, rtm, clogs },
249
+ buffers: { network, rtm, clogs, traces },
237
250
  counts,
238
251
  fetched,
239
252
  errors,
@@ -285,14 +298,14 @@ export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.
285
298
  }
286
299
  /**
287
300
  * Count items inside a buffer response. Handles the `{ calls: [...] }`,
288
- * `{ events: [...] }`, `{ clogs: [...] }` shapes the radar HTTP server
289
- * uses. Falls back to 0 on malformed responses.
301
+ * `{ events: [...] }`, `{ clogs: [...] }`, `{ traces: [...] }` shapes the
302
+ * radar HTTP server uses. Falls back to 0 on malformed responses.
290
303
  */
291
304
  function countItems(body) {
292
305
  if (!body || typeof body !== "object")
293
306
  return 0;
294
307
  const b = body;
295
- for (const key of ["calls", "events", "clogs", "items"]) {
308
+ for (const key of ["calls", "events", "clogs", "traces", "items"]) {
296
309
  const v = b[key];
297
310
  if (Array.isArray(v))
298
311
  return v.length;
package/dist/mcp/tools.js CHANGED
@@ -102,10 +102,10 @@ export const SERVER_INSTRUCTIONS = [
102
102
  "When capture_snapshot returns, tell the user:",
103
103
  " 1. The result.zip_path if non-null (the single shareable file). Fall back to",
104
104
  " result.path (a directory) if zip_path is null.",
105
- " 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
105
+ " 2. A one-line summary of counts (network/rtm/clogs/traces/log_lines/screenshot).",
106
106
  " 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
107
107
  " compression, no skipping even under time pressure:",
108
- ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally."',
108
+ ' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, clog payloads, and performance trace spans. Review before sharing externally."',
109
109
  " 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
110
110
  " requested because the 1 MB read cap was the limiting factor.'",
111
111
  " 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
@@ -302,7 +302,7 @@ export const TOOL_DEFINITIONS = [
302
302
  },
303
303
  {
304
304
  name: "capture_snapshot",
305
- description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, and clog payloads — review before sharing externally.",
305
+ description: "Bundle the current Radar state into a single on-disk artifact for sharing. Writes four files to ~/.slack-radar/snapshots/snapshot-<ts>/: bundle.json (network + RTM + clog + trace buffers, up to 1000 items each — a bulk export, much larger than point queries like get_network_call_detail), logcat.tail.log (last N lines of the active capture), screenshot.png (current screen), and README.txt (receiver-facing explainer). Use when the user says 'save this', 'file this as a bug', 'share this', 'bundle this up', 'attach to a ticket', 'make me a repro', or otherwise wants a shareable reproducible handoff. PRIVACY: bundle.json contains raw API request/response bodies including auth headers, RTM message content, user/team/channel IDs, clog payloads, and performance trace spans — review before sharing externally.",
306
306
  inputSchema: {
307
307
  type: "object",
308
308
  properties: {
@@ -77,6 +77,19 @@ export declare function pullDatabase(name: string, user?: string | null, force?:
77
77
  * (1) and (2), not from the open mode.
78
78
  */
79
79
  export declare function assertReadOnlyQuery(sql: string): void;
80
+ /**
81
+ * True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
82
+ * Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
83
+ * comment-prefixed queries before this routing matters.
84
+ */
85
+ export declare function isExplainQuery(sql: string): boolean;
86
+ /**
87
+ * sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
88
+ * Shape it into row objects so the web grid renders the plan readably.
89
+ */
90
+ export declare function parseExplainOutput(out: string): {
91
+ plan: string;
92
+ }[];
80
93
  /**
81
94
  * Run a read-only query against a (lazily pulled) local copy. The query is gated by
82
95
  * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
package/dist/shared/db.js CHANGED
@@ -353,6 +353,24 @@ export function assertReadOnlyQuery(sql) {
353
353
  throw new Error("that function/statement is not allowed (read-only, no file access)");
354
354
  }
355
355
  }
356
+ /**
357
+ * True for EXPLAIN / EXPLAIN QUERY PLAN, which sqlite3 prints as a text table (ignoring -json).
358
+ * Leading-token match; assertReadOnlyQuery gates the same leading keyword, so both reject on
359
+ * comment-prefixed queries before this routing matters.
360
+ */
361
+ export function isExplainQuery(sql) {
362
+ return /^\s*explain\b/i.test(sql);
363
+ }
364
+ /**
365
+ * sqlite3 ignores -json for EXPLAIN (prints text table instead of JSON).
366
+ * Shape it into row objects so the web grid renders the plan readably.
367
+ */
368
+ export function parseExplainOutput(out) {
369
+ return out
370
+ .split("\n")
371
+ .filter((line) => line.trim().length > 0)
372
+ .map((line) => ({ plan: line }));
373
+ }
356
374
  /**
357
375
  * Run a read-only query against a (lazily pulled) local copy. The query is gated by
358
376
  * assertReadOnlyQuery (see there for the full read-only/exfil rationale). Surfaces sqlite's
@@ -365,6 +383,12 @@ export async function queryDatabase(name, sql, user) {
365
383
  const local = localPath(name, u);
366
384
  if (!fs.existsSync(local))
367
385
  await pullDatabase(name, u);
386
+ // EXPLAIN / EXPLAIN QUERY PLAN are read-query-shaped (they pass the gate) but sqlite3 IGNORES
387
+ // -json for them: it prints a fixed-width opcode/plan TEXT table, not JSON, so JSON.parse below
388
+ // would throw "Unexpected token" and the user sees a confusing parse error for a query that
389
+ // actually ran. Handle them as text: one result row per output line under a single column, so
390
+ // the existing grid renders the plan readably instead of choking.
391
+ const explain = isExplainQuery(sql);
368
392
  let out;
369
393
  try {
370
394
  // Open the pulled copy NORMALLY (not `immutable`, not `-readonly`): pullDatabase brought
@@ -375,14 +399,15 @@ export async function queryDatabase(name, sql, user) {
375
399
  // is touched. `-safe` is the load-bearing read-only/exfil control (blocks ATTACH/readfile/
376
400
  // writefile/load_extension at the engine level) and holds in normal mode too. The path is
377
401
  // URI-encoded so spaces/specials are safe. Async exec so a large query does not block the
378
- // event loop.
402
+ // event loop. -json for normal queries; plain text for EXPLAIN (sqlite ignores -json there).
379
403
  const uri = `file:${encodeURI(local)}`;
380
- const r = await execFileP(sqliteBin(), ["-safe", "-json", uri, sql], {
404
+ const args = explain ? ["-safe", uri, sql] : ["-safe", "-json", uri, sql];
405
+ const r = await execFileP(sqliteBin(), args, {
381
406
  timeout: 15000,
382
407
  maxBuffer: SQLITE_MAX_BUFFER,
383
408
  encoding: "utf8",
384
409
  });
385
- out = String(r.stdout) || "[]";
410
+ out = String(r.stdout) || (explain ? "" : "[]");
386
411
  }
387
412
  catch (e) {
388
413
  const err = e;
@@ -390,6 +415,8 @@ export async function queryDatabase(name, sql, user) {
390
415
  const m = stderr.match(/(no such table:.*|no such column:.*|near ".*|syntax error.*|.*error.*)/i);
391
416
  throw new Error(m ? m[0].trim() : stderr.split("\n")[0] || "query failed");
392
417
  }
418
+ if (explain)
419
+ return parseExplainOutput(out);
393
420
  return JSON.parse(out);
394
421
  }
395
422
  /** Size in bytes of a pulled local copy for the given user, or 0 if not pulled. */
@@ -100,6 +100,12 @@ class Store {
100
100
  _localPushKey = null;
101
101
  _lastBoundLabel = null;
102
102
  _backfillToken = 0;
103
+ // True while the connection is in a dropped/waiting state. Lets pollHealth detect the
104
+ // drop->recover EDGE (device came back) so it can re-open the stream + backfill ONCE on
105
+ // recovery, instead of every poll. Without this, an auto-recovered tab flips the dot to
106
+ // connected but the dead EventSource is never re-opened and the device ring is never pulled,
107
+ // so the tab shows nothing until a manual refresh.
108
+ _dropped = false;
103
109
  _rafPending = false;
104
110
  _intervals = [];
105
111
  // ---- shared detail cache (bounded FIFO) ----
@@ -204,6 +210,7 @@ class Store {
204
210
  this._localPushKey = null;
205
211
  this._lastBoundLabel = null;
206
212
  this._backfillToken = 0;
213
+ this._dropped = false;
207
214
  this._rafPending = false;
208
215
  }
209
216
  // ---- start everything ----
@@ -317,7 +324,12 @@ class Store {
317
324
  this.closeStream();
318
325
  const es = new EventSource("/stream");
319
326
  es.onmessage = (m) => this.onFrame(m);
320
- es.onerror = () => this.setConn("waiting", "reconnecting…");
327
+ // An SSE error is the most direct signal the stream died; mark dropped so the next healthy
328
+ // poll re-opens + backfills (recovered()), not just flips the dot green.
329
+ es.onerror = () => {
330
+ this._dropped = true;
331
+ this.setConn("waiting", "reconnecting…");
332
+ };
321
333
  this._es = es;
322
334
  }
323
335
  closeStream() {
@@ -349,6 +361,10 @@ class Store {
349
361
  if (!STREAM_KINDS.includes(f.type))
350
362
  return;
351
363
  this._lastEventTs = Date.now();
364
+ // A live frame proves the stream healed itself (the browser EventSource auto-reconnects on a
365
+ // transient blip). Clear _dropped here so recovered() does NOT later tear down this healthy
366
+ // stream and re-open it: recovered() should only fire when the stream did NOT self-heal.
367
+ this._dropped = false;
352
368
  this.setConn("connected", "");
353
369
  const ev = f.event || {};
354
370
  ev._type = f.type;
@@ -593,6 +609,7 @@ class Store {
593
609
  async pollHealth() {
594
610
  if (Date.now() - this._lastEventTs < 6000) {
595
611
  this._healthMisses = 0;
612
+ this.recovered();
596
613
  this.setConn("connected", "");
597
614
  return;
598
615
  }
@@ -600,17 +617,31 @@ class Store {
600
617
  const h = await (await fetch("/health")).json();
601
618
  if (h.device) {
602
619
  this._healthMisses = 0;
620
+ this.recovered();
603
621
  this.setConn("connected", "");
604
622
  }
605
623
  else if (++this._healthMisses >= 2) {
624
+ this._dropped = true;
606
625
  this.setConn("waiting", "device offline, retrying…");
607
626
  }
608
627
  }
609
628
  catch {
610
- if (++this._healthMisses >= 2)
629
+ if (++this._healthMisses >= 2) {
630
+ this._dropped = true;
611
631
  this.setConn("waiting", "reconnecting…");
632
+ }
612
633
  }
613
634
  }
635
+ // Called when health flips back to connected. If we were in a dropped state, the EventSource
636
+ // did not survive the drop and the tab has been silent, so re-open the stream + backfill the
637
+ // device ring ONCE on the recovery edge. The _dropped guard makes this fire on the transition,
638
+ // not on every healthy poll (resyncStream clears the flag). The <6s-since-last-event fast path
639
+ // means a tab that never lost its stream skips this (live frames keep _lastEventTs fresh).
640
+ recovered() {
641
+ if (!this._dropped)
642
+ return;
643
+ this.resyncStream();
644
+ }
614
645
  async pollProfile() {
615
646
  try {
616
647
  const [profilesR, healthR] = await Promise.all([
@@ -635,6 +666,26 @@ class Store {
635
666
  /* leave the last label */
636
667
  }
637
668
  }
669
+ // Re-open the SSE stream and backfill the device ring for the ACTIVE streaming source, so the
670
+ // current tab repopulates from what the device already holds (not just new live events).
671
+ // Needed because the EventSource does not survive a device drop and pollSpec only re-applies
672
+ // (and so re-opens+backfills) on a spec-KEY change, which a reconnect is not. Guarded for
673
+ // streaming sources only, matching applySpec: db + blank own no SSE feed; log opens the stream
674
+ // but backfill() self-skips it (host-side capture, no device ring to replay).
675
+ //
676
+ // Clears _dropped itself so the two recovery entry points -- the manual reconnect() button and
677
+ // the auto recovered() edge -- collapse into one idempotent op: whichever fires first wins, the
678
+ // other sees _dropped false and no-ops. Without this, a manual reconnect on a waiting tab fires
679
+ // resyncStream() here AND again from recovered() once the health poll resolves, so the stream is
680
+ // opened, torn down, reopened, and the ring is backfilled twice.
681
+ resyncStream() {
682
+ this._dropped = false;
683
+ const src = this.spec.source;
684
+ if (this.spec.blank || !src || src === "db")
685
+ return;
686
+ this.openStream();
687
+ this.backfill();
688
+ }
638
689
  async reconnect() {
639
690
  try {
640
691
  await fetch("/enable", { method: "POST" });
@@ -645,6 +696,7 @@ class Store {
645
696
  this.pollHealth();
646
697
  this.pollProfile();
647
698
  this.pollSpec();
699
+ this.resyncStream();
648
700
  }
649
701
  }
650
702
  // ---- aggregate math (moved here from widgets per the plan: it belongs WITH the aggregate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.8.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",