@slack/radar-mcp 1.2.0 → 1.4.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.
@@ -61,8 +61,9 @@ const ADB = resolveAdbPath();
61
61
  /**
62
62
  * Parse the output of `ps -A -o USER,NAME` to extract the Android user ID
63
63
  * for the debug build process. When multiple profiles are running, prefers
64
- * the work profile (user ID > 0) since that's the typical debugging target.
65
- * Returns "0" for primary user, "10" for work profile, etc.
64
+ * a secondary user (user ID > 0) since that's the typical debugging target.
65
+ * Returns "0" for the primary user, or the first secondary user id (commonly
66
+ * "10", which is often a work profile, but the id alone cannot prove that).
66
67
  */
67
68
  export function parseUserFromPsOutput(psOutput) {
68
69
  if (!psOutput || !psOutput.trim())
@@ -77,9 +78,9 @@ export function parseUserFromPsOutput(psOutput) {
77
78
  }
78
79
  if (userIds.length === 0)
79
80
  return "0";
80
- // Prefer work profile (non-zero user ID) when multiple profiles are running
81
- const workProfile = userIds.find((id) => id !== "0");
82
- return workProfile ?? userIds[0];
81
+ // Prefer a secondary user (non-zero user ID) when multiple profiles are running
82
+ const secondaryUser = userIds.find((id) => id !== "0");
83
+ return secondaryUser ?? userIds[0];
83
84
  }
84
85
  export class AndroidTransport {
85
86
  platform = "android";
@@ -137,7 +138,7 @@ export class AndroidTransport {
137
138
  this.detectedUserId = null;
138
139
  this.radarEnabled = false;
139
140
  this.lastForwardError = null;
140
- stopCapture();
141
+ // Capture has its own idle timer; not stopped on activation reset (see logcat-capture.ts).
141
142
  }
142
143
  isForwarded() {
143
144
  return this.portForwarded;
@@ -228,7 +229,7 @@ export class AndroidTransport {
228
229
  const id = match[1];
229
230
  profiles.push({
230
231
  userId: id,
231
- label: id === "0" ? "Personal (user 0)" : `Work profile (user ${id})`,
232
+ label: id === "0" ? "Personal (user 0)" : `Secondary (user ${id}, e.g. a work profile)`,
232
233
  });
233
234
  }
234
235
  }
@@ -4,3 +4,9 @@ export declare const RADAR_PORT = 8099;
4
4
  export declare const RADAR_HOST = "127.0.0.1";
5
5
  /** Max events to retain in each local SSE buffer. */
6
6
  export declare const MAX_LOCAL_BUFFER = 500;
7
+ /**
8
+ * Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
9
+ * tool POSTs its spec to the same port the web server listens on; both honor the
10
+ * `SLACK_RADAR_WEB_PORT` env override.
11
+ */
12
+ export declare const WEB_PORT_DEFAULT = 8100;
@@ -4,3 +4,9 @@ export const RADAR_PORT = 8099;
4
4
  export const RADAR_HOST = "127.0.0.1";
5
5
  /** Max events to retain in each local SSE buffer. */
6
6
  export const MAX_LOCAL_BUFFER = 500;
7
+ /**
8
+ * Default port for the web dashboard. Shared so the MCP `open_radar_dashboard`
9
+ * tool POSTs its spec to the same port the web server listens on; both honor the
10
+ * `SLACK_RADAR_WEB_PORT` env override.
11
+ */
12
+ export const WEB_PORT_DEFAULT = 8100;
@@ -3,6 +3,9 @@ export interface CaptureState {
3
3
  child: ChildProcess | null;
4
4
  filePath: string | null;
5
5
  startedAt: number | null;
6
+ lastReadAt: number | null;
7
+ idleTimer: ReturnType<typeof setTimeout> | null;
8
+ captureIdleMs: number;
6
9
  }
7
10
  /**
8
11
  * Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
@@ -12,6 +15,18 @@ export declare function gcOldCaptures(dir?: string, now?: number): number;
12
15
  export declare function buildCaptureFilePath(now?: number): string;
13
16
  export declare function isCapturing(): boolean;
14
17
  export declare function getCaptureFilePath(): string | null;
18
+ /**
19
+ * Override the default capture-idle timeout. Internal — not currently exposed
20
+ * to MCP callers; reserved for future tuning. Values <= 0, NaN, or past
21
+ * `MAX_SETTIMEOUT_MS` fall back to the default so the timer can never
22
+ * pathologically collapse to an immediate-fire.
23
+ */
24
+ export declare function setCaptureIdleMs(ms: number): void;
25
+ export declare function _getCaptureIdleMsForTest(): number;
26
+ export declare function _hasIdleTimerForTest(): boolean;
27
+ export declare function _rescheduleIdleTimerForTest(): void;
28
+ export declare function _touchLastReadForTest(now?: number): void;
29
+ export declare function _setStateForTest(patch: Partial<CaptureState>): void;
15
30
  /**
16
31
  * Start streaming logcat to a session-scoped file. Idempotent: a second call
17
32
  * while the first capture is live is a no-op. If a prior capture's child is
@@ -17,10 +17,14 @@ const LOG_DIR = path.join(os.homedir(), ".slack-radar", "logs");
17
17
  const ROTATION_BYTES = 100 * 1024 * 1024;
18
18
  const ROTATION_KEEP_BYTES = 50 * 1024 * 1024;
19
19
  const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
+ const DEFAULT_CAPTURE_IDLE_MS = 15 * 60 * 1000;
20
21
  const state = {
21
22
  child: null,
22
23
  filePath: null,
23
24
  startedAt: null,
25
+ lastReadAt: null,
26
+ idleTimer: null,
27
+ captureIdleMs: DEFAULT_CAPTURE_IDLE_MS,
24
28
  };
25
29
  function ensureLogDir() {
26
30
  if (!fs.existsSync(LOG_DIR)) {
@@ -61,6 +65,88 @@ export function isCapturing() {
61
65
  export function getCaptureFilePath() {
62
66
  return state.filePath;
63
67
  }
68
+ // Node's setTimeout treats values past 2^31-1 ms as 1 ms (fires immediately),
69
+ // which would instantly kill capture. Clamp to the max safe value here.
70
+ const MAX_SETTIMEOUT_MS = 2_147_483_647;
71
+ /**
72
+ * Schedule (or reschedule) the capture-idle timer to fire when
73
+ * `state.captureIdleMs` elapses without a new read. Called on every
74
+ * successful `readRecentLogs` and at `startCapture` boot so the timer is
75
+ * always pointing at the most recent activity.
76
+ *
77
+ * The scheduled callback closes over the timer handle it created and only
78
+ * fires `stopCapture()` if the module's current `state.idleTimer` is
79
+ * still THAT same handle. This defends against the case where a previously
80
+ * scheduled (but not yet fired) callback gets queued to run AFTER a
81
+ * subsequent reschedule or `stopCapture` — without identity comparison it
82
+ * would incorrectly kill a freshly-rescheduled capture.
83
+ */
84
+ function rescheduleIdleTimer() {
85
+ if (state.idleTimer)
86
+ clearTimeout(state.idleTimer);
87
+ const delay = Math.min(state.captureIdleMs, MAX_SETTIMEOUT_MS);
88
+ const thisTimer = setTimeout(() => {
89
+ // Identity check: if state was cleared or a newer timer replaced us,
90
+ // do nothing. This prevents a stale queued callback from tearing down
91
+ // a fresh capture.
92
+ if (state.idleTimer !== thisTimer)
93
+ return;
94
+ stopCapture();
95
+ }, delay);
96
+ state.idleTimer = thisTimer;
97
+ // Node timers keep the event loop alive. For a long-running MCP this is
98
+ // fine, but for short-lived callers we do not want the timer to be the
99
+ // last thing keeping the process alive.
100
+ if (typeof thisTimer.unref === "function")
101
+ thisTimer.unref();
102
+ }
103
+ // Record that a read of the capture file just happened. Keeps the capture
104
+ // idle timer sliding forward so an actively-used capture does not time out.
105
+ function touchLastRead(now = Date.now()) {
106
+ state.lastReadAt = now;
107
+ if (isCapturing())
108
+ rescheduleIdleTimer();
109
+ }
110
+ /**
111
+ * Override the default capture-idle timeout. Internal — not currently exposed
112
+ * to MCP callers; reserved for future tuning. Values <= 0, NaN, or past
113
+ * `MAX_SETTIMEOUT_MS` fall back to the default so the timer can never
114
+ * pathologically collapse to an immediate-fire.
115
+ */
116
+ export function setCaptureIdleMs(ms) {
117
+ const valid = typeof ms === "number" && ms > 0 && ms <= MAX_SETTIMEOUT_MS;
118
+ state.captureIdleMs = valid ? ms : DEFAULT_CAPTURE_IDLE_MS;
119
+ if (isCapturing())
120
+ rescheduleIdleTimer();
121
+ }
122
+ // Test-only accessors so unit tests can assert on state that is otherwise
123
+ // module-private. Must never be called from production code paths.
124
+ export function _getCaptureIdleMsForTest() {
125
+ return state.captureIdleMs;
126
+ }
127
+ export function _hasIdleTimerForTest() {
128
+ return state.idleTimer !== null;
129
+ }
130
+ export function _rescheduleIdleTimerForTest() {
131
+ rescheduleIdleTimer();
132
+ }
133
+ export function _touchLastReadForTest(now) {
134
+ touchLastRead(now);
135
+ }
136
+ // Enforces the state-machine invariants the tests rely on so `Object.assign`
137
+ // cannot silently produce an impossible configuration (e.g. an idle timer
138
+ // pointing at a dead capture). Throws on misuse; callers that legitimately
139
+ // want to clear state should pass matched pairs.
140
+ export function _setStateForTest(patch) {
141
+ const next = { ...state, ...patch };
142
+ if (next.idleTimer !== null && next.child === null) {
143
+ throw new Error("_setStateForTest invariant: idleTimer must be null when child is null");
144
+ }
145
+ if (next.filePath !== null && next.child === null) {
146
+ throw new Error("_setStateForTest invariant: filePath must be null when child is null");
147
+ }
148
+ Object.assign(state, patch);
149
+ }
64
150
  /**
65
151
  * Start streaming logcat to a session-scoped file. Idempotent: a second call
66
152
  * while the first capture is live is a no-op. If a prior capture's child is
@@ -90,6 +176,11 @@ export function startCapture(adbPath) {
90
176
  }
91
177
  if (state.child === child) {
92
178
  state.child = null;
179
+ // Clear the idle timer: no capture to kill anymore.
180
+ if (state.idleTimer) {
181
+ clearTimeout(state.idleTimer);
182
+ state.idleTimer = null;
183
+ }
93
184
  }
94
185
  });
95
186
  child.on("error", () => {
@@ -103,11 +194,23 @@ export function startCapture(adbPath) {
103
194
  state.child = null;
104
195
  state.filePath = null;
105
196
  state.startedAt = null;
197
+ state.lastReadAt = null;
198
+ // Clear the idle timer we created for this (now-dead) capture so it
199
+ // cannot fire against stale state.
200
+ if (state.idleTimer) {
201
+ clearTimeout(state.idleTimer);
202
+ state.idleTimer = null;
203
+ }
106
204
  }
107
205
  });
108
206
  state.child = child;
109
207
  state.filePath = filePath;
110
208
  state.startedAt = Date.now();
209
+ state.lastReadAt = state.startedAt;
210
+ // Capture-idle timer starts fresh with the new child. Activation may
211
+ // time out (5 min) long before this fires, but capture keeps running
212
+ // so reads into recent history remain possible.
213
+ rescheduleIdleTimer();
111
214
  // Child bring-up warmup is handled at the MCP handler boundary
112
215
  // (handleSessionEnable / handleActivateForUser add a small async delay
113
216
  // before returning). Doing it here would require either blocking the
@@ -127,9 +230,14 @@ export function startCapture(adbPath) {
127
230
  }
128
231
  export function stopCapture() {
129
232
  const child = state.child;
233
+ const timer = state.idleTimer;
130
234
  state.child = null;
131
235
  state.filePath = null;
132
236
  state.startedAt = null;
237
+ state.lastReadAt = null;
238
+ state.idleTimer = null;
239
+ if (timer)
240
+ clearTimeout(timer);
133
241
  if (child && child.exitCode === null) {
134
242
  try {
135
243
  child.kill("SIGTERM");
@@ -270,6 +378,16 @@ export function readRecentLogs(tag, limit, grep) {
270
378
  catch {
271
379
  return null;
272
380
  }
381
+ // Slide the capture-idle timer forward whenever a read hits a HEALTHY
382
+ // capture child. A freshly spawned child may have written zero bytes to
383
+ // the file if there is no device traffic yet — that is not a sign of
384
+ // death. `isCapturing()` checks the child process itself (spawned, not
385
+ // exited), which is the authoritative health signal. The `exit` /
386
+ // `error` handlers will null out the child if it dies, and those same
387
+ // handlers clear the idle timer so no stale callback can fire.
388
+ if (isCapturing()) {
389
+ touchLastRead();
390
+ }
273
391
  const lines = tail.split("\n").filter(Boolean);
274
392
  return filterLogLines(lines, tag, limit, grep);
275
393
  }
@@ -55,6 +55,15 @@ export interface DeviceTransport {
55
55
  * @returns Array of matching log lines
56
56
  */
57
57
  getRecentLogs(tag: string, limit: number, grep?: string): string[];
58
+ /**
59
+ * Returns the currently active user ID, or null if none is set. On Android
60
+ * this is the profile (e.g. "0" for personal, "10" for work); on iOS it
61
+ * is typically null (single-user device) or a synthetic identifier.
62
+ *
63
+ * Required so snapshot tooling and other callers do not fall back to
64
+ * unchecked casts when asking "which profile did this capture?".
65
+ */
66
+ getActiveUserId(): string | null;
58
67
  /**
59
68
  * Returns the last error message from an `ensureForward()` failure, or null
60
69
  * if the last attempt succeeded or has not happened. Lets callers surface
@@ -88,10 +97,4 @@ export interface DeviceTransport {
88
97
  * @param timeoutMinutes Auto-shutdown timeout in minutes
89
98
  */
90
99
  activateForUser?(userId: string, timeoutMinutes: number): void;
91
- /**
92
- * Returns the currently active user ID, or null if not set. Surfaced in
93
- * list_profiles output so callers can tell which profile the current
94
- * session is targeting without making a second call. Optional.
95
- */
96
- getActiveUserId?(): string | null;
97
100
  }
package/dist/web/bin.js CHANGED
@@ -1,35 +1,50 @@
1
1
  #!/usr/bin/env node
2
- import { createServer, WEB_PORT } from "./server.js";
3
2
  import { execSync } from "child_process";
4
- const server = createServer();
5
- server.on("error", (err) => {
6
- if (err.code === "EADDRINUSE") {
7
- console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
8
- `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
9
- `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar-web`);
10
- process.exit(1);
11
- }
12
- throw err;
13
- });
14
- server.listen(WEB_PORT, () => {
15
- const url = `http://localhost:${WEB_PORT}`;
16
- console.log(`Slack Radar Web running at ${url}`);
17
- try {
18
- if (process.platform === "darwin") {
19
- execSync(`open ${url}`, { stdio: "ignore" });
3
+ import { createServer, WEB_PORT } from "./server.js";
4
+ /**
5
+ * Start the web dashboard server, open the browser, and wire shutdown signals.
6
+ * Extracted so both the dedicated `slack-radar-web` bin and the default bin's
7
+ * `web` subcommand dispatcher launch the dashboard the same way.
8
+ */
9
+ function startWebServer() {
10
+ const server = createServer();
11
+ server.on("error", (err) => {
12
+ if (err.code === "EADDRINUSE") {
13
+ console.error(`Port ${WEB_PORT} is already in use. Another instance may be running.\n` +
14
+ `Kill it with: lsof -ti :${WEB_PORT} | xargs kill\n` +
15
+ `Or set a different port: SLACK_RADAR_WEB_PORT=8101 slack-radar web`);
16
+ process.exit(1);
17
+ }
18
+ throw err;
19
+ });
20
+ server.listen(WEB_PORT, () => {
21
+ const url = `http://localhost:${WEB_PORT}`;
22
+ console.log(`Slack Radar Web running at ${url}`);
23
+ // Auto-open the browser for the HUMAN launcher (`slack-radar web` / `slack-radar-web`),
24
+ // but NOT when spawned as a bridge child by the open_radar_dashboard MCP tool
25
+ // (SLACK_RADAR_NO_OPEN=1). The Claude Code session must not pop a browser window the user
26
+ // did not ask for — the same no-auto-open-browser rule the SERVER_INSTRUCTIONS state for
27
+ // /radar-ui. The tool returns the URL instead, for the Claude Code session to surface.
28
+ if (process.env.SLACK_RADAR_NO_OPEN)
29
+ return;
30
+ try {
31
+ if (process.platform === "darwin") {
32
+ execSync(`open ${url}`, { stdio: "ignore" });
33
+ }
34
+ else if (process.platform === "linux") {
35
+ execSync(`xdg-open ${url}`, { stdio: "ignore" });
36
+ }
20
37
  }
21
- else if (process.platform === "linux") {
22
- execSync(`xdg-open ${url}`, { stdio: "ignore" });
38
+ catch {
39
+ // Browser open is best-effort
23
40
  }
24
- }
25
- catch {
26
- // Browser open is best-effort
27
- }
28
- });
29
- function shutdown() {
30
- console.log("\nShutting down…");
31
- server.close(() => process.exit(0));
32
- setTimeout(() => process.exit(0), 2000);
41
+ });
42
+ const shutdown = () => {
43
+ console.log("\nShutting down…");
44
+ server.close(() => process.exit(0));
45
+ setTimeout(() => process.exit(0), 2000);
46
+ };
47
+ process.on("SIGINT", shutdown);
48
+ process.on("SIGTERM", shutdown);
33
49
  }
34
- process.on("SIGINT", shutdown);
35
- process.on("SIGTERM", shutdown);
50
+ startWebServer();