@slack/radar-mcp 1.6.0 → 1.7.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/README.md CHANGED
@@ -45,9 +45,10 @@ Opens `http://localhost:8100` automatically. Set `SLACK_RADAR_WEB_PORT` to use a
45
45
  * **RTM** — real-time websocket events
46
46
  * **Clogs** — analytics events, click one to see its payload
47
47
  * **Logs** — device logcat, filtered to the Slack app (switch app or clear the filter)
48
+ * **Database** — read-only browser for the on-device SQLite stores (pick a database, then a table, or run a read-only query). See the data-handling note below.
48
49
  * **Custom** — a tab Claude builds on request. Ask a Claude Code session in your terminal for a view the pre-built tabs do not cover (e.g. "graph calls per second and flag the slow ones"); it authors the view via the `open_radar_dashboard` tool and it appears here. Persists until you refresh the page.
49
50
 
50
- Each tab has a counter, rate, sparkline, graph, a filter / sort / pause feed, and a payload inspector. Switching tabs replays what you have already seen this session (the browser caches it, bounded by count and age) so the view does not reset to empty. ADB forwarding and device activation are handled for you; a reconnect control re-arms after a device sleep.
51
+ Each tab has a rate, sparkline, graph, a filter / sort / pause feed, and a payload inspector. Switching tabs replays what you have already seen this session (the browser caches it, bounded by count and age) so the view does not reset to empty. ADB forwarding and device activation are handled for you; a reconnect control re-arms after a device sleep. The dashboard follows your system light/dark setting by default; the theme toggle in the header overrides it and is remembered.
51
52
 
52
53
  ### Screen overlay
53
54
 
@@ -89,7 +90,7 @@ Radar starts **disabled** each session. All tools except `ping` return an error
89
90
  | `wait_for_events` | Block until matching events arrive via SSE stream. Idle-timeout based, not polling. |
90
91
  | `search` | Full-text search across network, RTM, and clog buffers. |
91
92
  | `get_app_state` | Current Activity/Fragment state via `dumpsys`. Shows visible screen, parameters, tab state. |
92
- | `get_recent_logs` | Logcat entries filtered by tag. For Timber debug logs (`FKDebug`, `SlackRadar`, etc.). |
93
+ | `get_recent_logs` | Recent device logs, optionally filtered by tag (omit `tag` for all lines). Reads the app's own debug log buffer first, falling back to logcat; the result's `source` field (`app_buffer` / `logcat_capture` / `logcat_ring`) names which one served it. For Timber debug logs (`FKDebug`, `SlackRadar`, etc.). |
93
94
  | `screenshot` | Capture device screen as PNG. 720px default, `full_res=true` for full resolution. |
94
95
  | `record_screen` | Record short screen video (MP4). Default 5s, max 30s. |
95
96
  | `clear_buffers` | Clear all captured network calls and RTM events from device and local buffers. |
@@ -146,7 +147,7 @@ This tool captures unredacted network traffic from your debug Slack build, inclu
146
147
  * RTM WebSocket payloads (every sent and received message)
147
148
  * Analytics (clog) events serialized as JSON
148
149
  * Screenshots and short screen recordings on demand
149
- * Logcat entries filtered by tag
150
+ * Device logs (the app's own debug buffer, with a logcat fallback), optionally filtered by tag
150
151
 
151
152
  Auth tokens and session cookies will appear in captured data. Captured data flows to your MCP client (Claude Code), which may send relevant portions to the configured AI provider per its own data handling policy.
152
153
 
@@ -0,0 +1 @@
1
+ export declare function buildApiPath(name: string, args: Record<string, unknown>): string | null;
@@ -0,0 +1,108 @@
1
+ // Maps an MCP tool name + its args to the device HTTP path the MCP server proxies it to.
2
+ // Extracted from index.ts (where it was nested inside runMcpServer, so unreachable by a unit
3
+ // test) into its own module so the device-route contract for EVERY proxied tool -- network,
4
+ // rtm, clog, trace, search, and the detail-by-id variants -- is pinned by a table test
5
+ // (tests/api-paths.test.mjs). Pure: no I/O, no device, just string building.
6
+ //
7
+ // The arg shapes here are the query-param subset each tool sends; they intentionally duplicate
8
+ // the (wider) handler arg interfaces in index.ts rather than couple this pure module to them.
9
+ // Build the device path for a detail-by-id tool. The MCP SDK casts args without enforcing the
10
+ // JSON Schema (no zod), so `id` can arrive as a string carrying "/" or ".." even though the
11
+ // schema says number. All four detail tools (network/rtm/clog/trace) read the device's OWN
12
+ // forwarded server with no shell/fs, so the blast radius is small, but an un-encoded id is still
13
+ // a path-traversal smell. Accept only a non-negative integer id; anything else returns null
14
+ // (the caller reports "unknown tool"/no path rather than firing a malformed device request).
15
+ function detailPath(resource, id) {
16
+ let n;
17
+ if (typeof id === "number") {
18
+ n = id;
19
+ }
20
+ else if (typeof id === "string" && /^\d+$/.test(id.trim())) {
21
+ // a clean digit string only -- Number("") and Number(" ") are 0 and Number("1/../x") is NaN,
22
+ // but "1e3"/"0x10"/" 5 " would also coerce; the \d+ test rejects all of those outright.
23
+ n = Number(id.trim());
24
+ }
25
+ else {
26
+ return null;
27
+ }
28
+ if (!Number.isInteger(n) || n < 0)
29
+ return null;
30
+ return `/api/${resource}/${n}`;
31
+ }
32
+ export function buildApiPath(name, args) {
33
+ switch (name) {
34
+ case "get_recent_network_calls": {
35
+ const a = args;
36
+ const params = new URLSearchParams();
37
+ if (a?.limit)
38
+ params.set("limit", String(a.limit));
39
+ if (a?.url_filter)
40
+ params.set("url_filter", a.url_filter);
41
+ if (a?.status_code)
42
+ params.set("status_code", String(a.status_code));
43
+ const qs = params.toString();
44
+ return `/api/network${qs ? "?" + qs : ""}`;
45
+ }
46
+ case "get_network_call_detail": {
47
+ const a = args;
48
+ return detailPath("network", a.id);
49
+ }
50
+ case "get_recent_rtm_events": {
51
+ const a = args;
52
+ const params = new URLSearchParams();
53
+ if (a?.limit)
54
+ params.set("limit", String(a.limit));
55
+ if (a?.event_type)
56
+ params.set("event_type", a.event_type);
57
+ if (a?.direction)
58
+ params.set("direction", a.direction);
59
+ if (a?.channel)
60
+ params.set("channel", a.channel);
61
+ const qs = params.toString();
62
+ return `/api/rtm${qs ? "?" + qs : ""}`;
63
+ }
64
+ case "get_rtm_event_detail": {
65
+ const a = args;
66
+ return detailPath("rtm", a.id);
67
+ }
68
+ case "get_recent_clogs": {
69
+ const a = args;
70
+ const params = new URLSearchParams();
71
+ if (a?.limit)
72
+ params.set("limit", String(a.limit));
73
+ if (a?.name)
74
+ params.set("name", a.name);
75
+ const qs = params.toString();
76
+ return `/api/clogs${qs ? "?" + qs : ""}`;
77
+ }
78
+ case "get_clog_detail": {
79
+ const a = args;
80
+ return detailPath("clogs", a.id);
81
+ }
82
+ case "get_recent_traces": {
83
+ const a = args;
84
+ const params = new URLSearchParams();
85
+ // Default the limit when absent: traces can be large and an omitted limit would otherwise
86
+ // rely on an unverified device-side default and stream an unbounded result into the model
87
+ // context. 50 matches the other recent-* tools' practical page size.
88
+ params.set("limit", String(a?.limit && a.limit > 0 ? a.limit : 50));
89
+ if (a?.name)
90
+ params.set("name", a.name);
91
+ return `/api/traces?${params.toString()}`;
92
+ }
93
+ case "get_trace_detail": {
94
+ const a = args;
95
+ return detailPath("traces", a.id);
96
+ }
97
+ case "search": {
98
+ const a = args;
99
+ const params = new URLSearchParams();
100
+ params.set("q", a.query);
101
+ if (a?.limit)
102
+ params.set("limit", String(a.limit));
103
+ return `/api/search?${params.toString()}`;
104
+ }
105
+ default:
106
+ return null;
107
+ }
108
+ }
package/dist/mcp/index.js CHANGED
@@ -12,9 +12,11 @@ import { RADAR_HOST, RADAR_PORT, WEB_PORT_DEFAULT } from "../shared/constants.js
12
12
  import { stopCapture } from "../shared/logcat-capture.js";
13
13
  import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
14
14
  import { buildBufferPath, createSnapshot } from "./snapshot.js";
15
+ import { clampLogLimit, shapeLogsResult } from "./logs-result.js";
15
16
  import { handleListDatabases, handleListTables, handleQueryDatabase, } from "./db-tools.js";
16
17
  import { cleanupPulledDatabases, initializeDatabasePath, resetDbState, } from "../shared/db.js";
17
18
  import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS, sessionGate } from "./tools.js";
19
+ import { buildApiPath } from "./api-paths.js";
18
20
  // --- Subcommand + bin-name dispatch ---
19
21
  //
20
22
  // One entrypoint, two front doors, chosen so each invocation does the obvious thing:
@@ -198,8 +200,30 @@ async function runMcpServer() {
198
200
  process.once(sig, cleanupPulledDatabases);
199
201
  }
200
202
  let sessionEnabled = false;
203
+ // Host-side ceiling on a proxied device response. radarRequest forwards the device body straight
204
+ // into the model context, so an unbounded response (large trace/clog payloads) could flood it.
205
+ // When the cap trips we stop reading, tear the socket down, and return a truncation marker
206
+ // rather than a partial JSON blob the parser would choke on. 1MB is generous for a point query;
207
+ // bulk exports go through capture_snapshot, not this path.
208
+ const MAX_RADAR_RESPONSE_BYTES = 1024 * 1024;
201
209
  function radarRequest(requestPath) {
202
210
  return new Promise((resolve, reject) => {
211
+ // settled-guard: req.destroy() on the cap path fires "error" (ECONNRESET) AFTER we have
212
+ // already resolved the truncation, so every settle goes through these wrappers and the
213
+ // second one is a no-op. Without this the abort would reject an already-resolved promise.
214
+ let settled = false;
215
+ const done = (r) => {
216
+ if (settled)
217
+ return;
218
+ settled = true;
219
+ resolve(r);
220
+ };
221
+ const fail = (e) => {
222
+ if (settled)
223
+ return;
224
+ settled = true;
225
+ reject(e);
226
+ };
203
227
  const req = http.get({
204
228
  host: RADAR_HOST,
205
229
  port: RADAR_PORT,
@@ -207,22 +231,36 @@ async function runMcpServer() {
207
231
  timeout: 10000,
208
232
  }, (res) => {
209
233
  let data = "";
234
+ let bytes = 0;
210
235
  res.on("data", (chunk) => {
236
+ if (settled)
237
+ return;
238
+ bytes += chunk.length; // byte length, not String.length (UTF-8 over the wire)
239
+ if (bytes > MAX_RADAR_RESPONSE_BYTES) {
240
+ // Resolve the truncation NOW (not on "end" — req.destroy() aborts the response so
241
+ // "end" never fires; it surfaces as an "error" the settled-guard then swallows).
242
+ done({
243
+ error: `Device response exceeded the ${Math.round(MAX_RADAR_RESPONSE_BYTES / 1024)}KB response budget and was dropped to protect the model context. Narrow the query (lower limit, add a name/filter), or use capture_snapshot for a bulk export.`,
244
+ _truncated: true,
245
+ });
246
+ req.destroy();
247
+ return;
248
+ }
211
249
  data += chunk;
212
250
  });
213
251
  res.on("end", () => {
214
252
  try {
215
- resolve(JSON.parse(data));
253
+ done(JSON.parse(data));
216
254
  }
217
255
  catch {
218
- resolve({ raw: data });
256
+ done({ raw: data });
219
257
  }
220
258
  });
221
259
  });
222
- req.on("error", reject);
260
+ req.on("error", fail);
223
261
  req.on("timeout", () => {
224
262
  req.destroy();
225
- reject(new Error("Request timed out"));
263
+ fail(new Error("Request timed out"));
226
264
  });
227
265
  });
228
266
  }
@@ -427,18 +465,14 @@ async function runMcpServer() {
427
465
  }
428
466
  function handleGetRecentLogs(args) {
429
467
  try {
430
- const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
431
- // Surface the capture file path so the model knows which on-disk file
432
- // produced these lines. Helps avoid cross-session staleness (e.g. the
433
- // model remembering an earlier profile's logs after a profile switch).
434
- const sourceFile = device.getCaptureFilePath?.() ?? null;
435
- return textResult({
436
- tag: args.tag,
437
- lines,
438
- count: lines.length,
439
- source_file: sourceFile,
440
- source: sourceFile ? "capture" : "adb_logcat_d",
441
- });
468
+ const tag = args.tag ?? "";
469
+ const limit = clampLogLimit(args.limit);
470
+ const result = device.getRecentLogs(tag, limit, args.grep);
471
+ // shapeLogsResult preserves the source label (app_buffer / logcat_capture / logcat_ring)
472
+ // that getRecentLogs returns, so the caller can tell a true-empty result from a silent
473
+ // fallback to an inferior source (0 lines from app_buffer means the tag is really absent;
474
+ // 0 from logcat_ring may mean the better source was unreachable).
475
+ return textResult(shapeLogsResult(tag, result));
442
476
  }
443
477
  catch (e) {
444
478
  return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
@@ -517,70 +551,8 @@ async function runMcpServer() {
517
551
  }, true);
518
552
  }
519
553
  }
520
- function buildApiPath(name, args) {
521
- switch (name) {
522
- case "get_recent_network_calls": {
523
- const a = args;
524
- const params = new URLSearchParams();
525
- if (a?.limit)
526
- params.set("limit", String(a.limit));
527
- if (a?.url_filter)
528
- params.set("url_filter", a.url_filter);
529
- if (a?.status_code)
530
- params.set("status_code", String(a.status_code));
531
- const qs = params.toString();
532
- return `/api/network${qs ? "?" + qs : ""}`;
533
- }
534
- case "get_network_call_detail": {
535
- const a = args;
536
- return `/api/network/${a.id}`;
537
- }
538
- case "get_recent_rtm_events": {
539
- const a = args;
540
- const params = new URLSearchParams();
541
- if (a?.limit)
542
- params.set("limit", String(a.limit));
543
- if (a?.event_type)
544
- params.set("event_type", a.event_type);
545
- if (a?.direction)
546
- params.set("direction", a.direction);
547
- if (a?.channel)
548
- params.set("channel", a.channel);
549
- const qs = params.toString();
550
- return `/api/rtm${qs ? "?" + qs : ""}`;
551
- }
552
- case "get_rtm_event_detail": {
553
- const a = args;
554
- return `/api/rtm/${a.id}`;
555
- }
556
- case "get_recent_clogs": {
557
- const a = args;
558
- const params = new URLSearchParams();
559
- if (a?.limit)
560
- params.set("limit", String(a.limit));
561
- if (a?.name)
562
- params.set("name", a.name);
563
- const qs = params.toString();
564
- return `/api/clogs${qs ? "?" + qs : ""}`;
565
- }
566
- case "get_clog_detail": {
567
- const a = args;
568
- return `/api/clogs/${a.id}`;
569
- }
570
- case "search": {
571
- const a = args;
572
- const params = new URLSearchParams();
573
- params.set("q", a.query);
574
- if (a?.limit)
575
- params.set("limit", String(a.limit));
576
- return `/api/search?${params.toString()}`;
577
- }
578
- default:
579
- return null;
580
- }
581
- }
582
554
  // --- MCP Server ---
583
- const server = new Server({ name: "slack-radar", version: "1.6.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
555
+ const server = new Server({ name: "slack-radar", version: "1.7.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
584
556
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
585
557
  tools: TOOL_DEFINITIONS,
586
558
  }));
@@ -0,0 +1,9 @@
1
+ import type { LogResult } from "../shared/transport.js";
2
+ export interface LogsToolResult {
3
+ tag: string;
4
+ lines: string[];
5
+ count: number;
6
+ source?: string;
7
+ }
8
+ export declare function clampLogLimit(limit: number | undefined): number;
9
+ export declare function shapeLogsResult(tag: string, result: LogResult): LogsToolResult;
@@ -0,0 +1,15 @@
1
+ // Clamp the requested line count. With tag optional, a no-tag call returns the whole buffer
2
+ // (thousands of lines, ~1.5MB), so an unbounded limit would flood the model context with
3
+ // potentially PII-bearing log lines. 1000 is a generous ceiling for a debug read; default 50.
4
+ export function clampLogLimit(limit) {
5
+ return Math.min(Math.max(limit || 50, 1), 1000);
6
+ }
7
+ // Shape the device result into the tool payload, preserving the source label verbatim.
8
+ export function shapeLogsResult(tag, result) {
9
+ return {
10
+ tag,
11
+ lines: result.lines,
12
+ count: result.lines.length,
13
+ source: result.source,
14
+ };
15
+ }
package/dist/mcp/tools.js CHANGED
@@ -20,9 +20,10 @@ export const SERVER_INSTRUCTIONS = [
20
20
  '- "why isn\'t this loading" / "this is broken" -> screenshot + get_recent_network_calls(status_code=400 or 500)',
21
21
  '- "watch what happens when I..." -> clear_buffers then wait_for_events (DO NOT filter unless the user specifies a type)',
22
22
  '- "did that clog fire" / "check analytics" -> get_recent_clogs(name="...")',
23
+ '- "why is this slow" / "how long did X take" / "show me the traces" / "check performance" -> get_recent_traces(name="...") then get_trace_detail',
23
24
  '- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
24
25
  '- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
25
- '- "check the debug logs" -> get_recent_logs(tag="...")',
26
+ '- "check the debug logs" -> get_recent_logs (tag is OPTIONAL: pass tag="FKDebug" to filter, or omit it for all recent lines)',
26
27
  '- "save this" / "file this as a bug" / "share this" / "bundle this" / "attach to ticket" / "give me a repro" / "snapshot this" / "preserve this" / "hold this state" / "save what you just saw" -> capture_snapshot',
27
28
  "",
28
29
  "Key behaviors:",
@@ -203,6 +204,30 @@ export const TOOL_DEFINITIONS = [
203
204
  },
204
205
  annotations: { readOnlyHint: true },
205
206
  },
207
+ {
208
+ name: "get_recent_traces",
209
+ description: "Get recent performance trace summaries (one row per completed span from the app's telemetry pipeline). Returns trace name, span name, and duration. Use get_trace_detail for the full span, including its tags and the parent/trace ids.",
210
+ inputSchema: {
211
+ type: "object",
212
+ properties: {
213
+ limit: { type: "number", description: "Max number of traces to return (default: 50)" },
214
+ name: { type: "string", description: "Filter by trace name or span name substring" },
215
+ },
216
+ },
217
+ annotations: { readOnlyHint: true },
218
+ },
219
+ {
220
+ name: "get_trace_detail",
221
+ description: "Get full details of a specific performance trace by ID: trace and span names, the trace and parent ids, start/end/duration, and the span tags.",
222
+ inputSchema: {
223
+ type: "object",
224
+ properties: {
225
+ id: { type: "number", description: "The trace ID from get_recent_traces" },
226
+ },
227
+ required: ["id"],
228
+ },
229
+ annotations: { readOnlyHint: true },
230
+ },
206
231
  {
207
232
  name: "wait_for_events",
208
233
  description: "Wait for matching events to arrive in real-time via the SSE stream.",
@@ -211,7 +236,7 @@ export const TOOL_DEFINITIONS = [
211
236
  properties: {
212
237
  event_type: { type: "string", description: "RTM event type to wait for" },
213
238
  channel: { type: "string", description: "Filter by channel ID" },
214
- source: { type: "string", description: "Wait for network calls or RTM events (default: both)" },
239
+ source: { type: "string", description: "Limit to one stream: network, rtm, clog, log, or trace (default: all)" },
215
240
  timeout_ms: { type: "number", description: "Max time to wait in ms (default: 30000)" },
216
241
  idle_timeout_ms: { type: "number", description: "Resolve after this many ms of silence (default: 3000)" },
217
242
  },
@@ -220,7 +245,7 @@ export const TOOL_DEFINITIONS = [
220
245
  },
221
246
  {
222
247
  name: "search",
223
- description: "Search across all captured network calls and RTM events.",
248
+ description: "Search across all captured network calls, RTM events, clogs, and performance traces.",
224
249
  inputSchema: {
225
250
  type: "object",
226
251
  properties: {
@@ -239,15 +264,14 @@ export const TOOL_DEFINITIONS = [
239
264
  },
240
265
  {
241
266
  name: "get_recent_logs",
242
- description: "Get recent device logs for the Slack debug build, filtered by tag. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
267
+ description: "Get recent device logs for the Slack debug build. Reads the app's own log buffer first (the DEBUG-and-above Timber lines the in-app Log Viewer shows, this app only), and falls back to an adb logcat snapshot when that buffer cannot be read. The result includes a `source` field (app_buffer / logcat_capture / logcat_ring) so you can tell a real empty result from a fallback. Leave `tag` out to get all recent lines. For native crashes, VERBOSE lines, or non-Slack processes, use adb logcat directly.",
243
268
  inputSchema: {
244
269
  type: "object",
245
270
  properties: {
246
- tag: { type: "string", description: "Substring to filter log lines by (matched anywhere in the line, not an exact tag-column match)" },
247
- limit: { type: "number", description: "Max number of lines to return (default: 50)" },
271
+ tag: { type: "string", description: "Optional substring to filter log lines by (matched anywhere in the line, not an exact tag-column match). Omit to return all recent lines." },
272
+ limit: { type: "number", description: "Max number of lines to return (default: 50, capped at 1000)" },
248
273
  grep: { type: "string", description: "Optional text to grep within the filtered output" },
249
274
  },
250
- required: ["tag"],
251
275
  },
252
276
  annotations: { readOnlyHint: true },
253
277
  },
@@ -1,4 +1,4 @@
1
- import type { DeviceTransport } from "./transport.js";
1
+ import type { DeviceTransport, LogResult } from "./transport.js";
2
2
  /**
3
3
  * Resolve the adb binary. MCP servers spawned from GUI launchers (Spotlight,
4
4
  * Raycast, Dock) do not inherit the user's shell PATH, so a bare `adb` call
@@ -44,7 +44,7 @@ export declare class AndroidTransport implements DeviceTransport {
44
44
  screenshot(fullRes: boolean): string;
45
45
  recordScreen(durationS: number): string;
46
46
  getAppState(): string;
47
- getRecentLogs(tag: string, limit: number, grep?: string): string[];
47
+ getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
48
48
  listProfiles(): {
49
49
  userId: string;
50
50
  label: string;
@@ -1,4 +1,4 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, execFileSync } from "child_process";
2
2
  import fs from "fs";
3
3
  import os from "os";
4
4
  import path from "path";
@@ -209,7 +209,7 @@ export class AndroidTransport {
209
209
  // sandbox -- matching how the DB browser threads its user.
210
210
  const appLog = readDebugLogBuffer(ADB, this.resolveActiveUser());
211
211
  if (appLog !== null) {
212
- return filterLogLines(appLog, tag, limit, grep);
212
+ return { lines: filterLogLines(appLog, tag, limit, grep), source: "app_buffer" };
213
213
  }
214
214
  // Fallback: read from the session capture file so lines cannot have been
215
215
  // evicted from the device ring buffer between enable and now.
@@ -220,20 +220,25 @@ export class AndroidTransport {
220
220
  rotateCaptureIfNeeded(ADB);
221
221
  const lines = readRecentLogs(tag, limit, grep);
222
222
  if (lines !== null)
223
- return lines;
223
+ return { lines, source: "logcat_capture" };
224
224
  }
225
225
  // Fallback: device ring buffer snapshot. Used when capture is not running
226
- // (activate() was skipped, child died, or the file was deleted).
227
- let cmd = `${ADB} logcat -d | grep -F "${tag}" | tail -${limit}`;
228
- if (grep) {
229
- cmd = `${ADB} logcat -d | grep -F "${tag}" | grep -i "${grep.replace(/"/g, '\\"')}" | tail -${limit}`;
230
- }
231
- const output = execSync(cmd, {
226
+ // (activate() was skipped, child died, or the file was deleted). No shell:
227
+ // dump the ring with execFileSync (argv, so a tag/grep with a backtick,
228
+ // $(...), or trailing backslash cannot break out of a command string) and do
229
+ // the tag/grep/limit filtering in pure JS via the same filterLogLines the
230
+ // other two sources use. An empty tag means "no filter" (includes("") is
231
+ // true), so the whole tail is returned.
232
+ const output = execFileSync(ADB, ["logcat", "-d"], {
232
233
  timeout: 10000,
233
- shell: "/bin/zsh",
234
234
  encoding: "utf-8",
235
+ maxBuffer: 64 * 1024 * 1024,
235
236
  });
236
- return output.trim().split("\n").filter(Boolean);
237
+ const lines = output.split("\n").filter(Boolean);
238
+ return {
239
+ lines: filterLogLines(lines, tag, limit, grep),
240
+ source: "logcat_ring",
241
+ };
237
242
  }
238
243
  listProfiles() {
239
244
  if (!ADB)
@@ -130,6 +130,16 @@ export declare function startLive(fps: number): LiveCast | null;
130
130
  /** Attach an HTTP response as a viewer of the live cast (after the multipart headers are written). */
131
131
  export declare function addViewer(L: LiveCast, res: NodeJS.WritableStream): void;
132
132
  export declare function removeViewer(res: NodeJS.WritableStream): void;
133
+ /**
134
+ * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
135
+ * recording. Either a viewer leaving or a recording stopping can be the last thing
136
+ * holding the cast, so both paths call this. The close-while-recording case needs it:
137
+ * the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
138
+ * resolves, so the viewer-leave finds recording still set and cannot reap; the cast
139
+ * would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
140
+ * after stopRecording closes that gap.
141
+ */
142
+ export declare function reapLiveIfIdle(): void;
133
143
  export declare function getLive(): LiveCast | null;
134
144
  export declare function lastFrame(): Buffer | null;
135
145
  export declare function primeIfNeeded(): Promise<void>;
@@ -461,7 +461,19 @@ export function removeViewer(res) {
461
461
  if (!scLive)
462
462
  return;
463
463
  scLive.viewers.delete(res);
464
- if (!scLive.viewers.size && !scLive.recording) {
464
+ reapLiveIfIdle();
465
+ }
466
+ /**
467
+ * Tear down the live cast when nothing is keeping it alive: no viewers AND no active
468
+ * recording. Either a viewer leaving or a recording stopping can be the last thing
469
+ * holding the cast, so both paths call this. The close-while-recording case needs it:
470
+ * the browser aborts the <img> (dropping the last viewer) BEFORE its record/stop POST
471
+ * resolves, so the viewer-leave finds recording still set and cannot reap; the cast
472
+ * would then run forever (screenrecord + ffmpeg) once the recording ends. Reaping again
473
+ * after stopRecording closes that gap.
474
+ */
475
+ export function reapLiveIfIdle() {
476
+ if (scLive && !scLive.viewers.size && !scLive.recording) {
465
477
  cleanupLive(scLive);
466
478
  scLive = null;
467
479
  }
@@ -6,7 +6,7 @@ export interface RadarEvent {
6
6
  [key: string]: unknown;
7
7
  }
8
8
  export interface ParsedSSE {
9
- type: "network" | "rtm";
9
+ type: "network" | "rtm" | "clog" | "log" | "trace";
10
10
  event: RadarEvent;
11
11
  }
12
12
  export declare const localNetworkBuffer: RadarEvent[];
@@ -6,6 +6,13 @@
6
6
  * device-specific operations. The MCP server and tool handlers
7
7
  * interact only through this interface.
8
8
  */
9
+ /** Which underlying source served a getRecentLogs result. */
10
+ export type LogSource = "app_buffer" | "logcat_capture" | "logcat_ring";
11
+ /** Lines plus the source that produced them. */
12
+ export interface LogResult {
13
+ lines: string[];
14
+ source: LogSource;
15
+ }
9
16
  export interface DeviceTransport {
10
17
  /** Human-readable platform name (e.g. "android", "ios"). */
11
18
  readonly platform: string;
@@ -47,14 +54,15 @@ export interface DeviceTransport {
47
54
  */
48
55
  getAppState(): string;
49
56
  /**
50
- * Get recent log entries filtered by tag.
57
+ * Get recent log entries, optionally filtered by tag.
51
58
  *
52
- * @param tag Log tag to filter by
59
+ * @param tag Log tag to filter by. Empty string returns every line (no tag filter).
53
60
  * @param limit Max number of lines
54
61
  * @param grep Optional text to further filter
55
- * @returns Array of matching log lines
62
+ * @returns The matching lines plus which source served them, so the caller can tell a
63
+ * true-empty result from a silent fallback to an inferior source.
56
64
  */
57
- getRecentLogs(tag: string, limit: number, grep?: string): string[];
65
+ getRecentLogs(tag: string, limit: number, grep?: string): LogResult;
58
66
  /**
59
67
  * Returns the currently active user ID, or null if none is set. On Android
60
68
  * this is the profile (e.g. "0" for personal, "10" for work); on iOS it