@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.
- package/README.md +16 -13
- package/dist/mcp/index.js +568 -347
- package/dist/mcp/snapshot.d.ts +63 -0
- package/dist/mcp/snapshot.js +301 -0
- package/dist/mcp/tools.js +165 -10
- package/dist/shared/android.d.ts +3 -2
- package/dist/shared/android.js +8 -7
- package/dist/shared/constants.d.ts +6 -0
- package/dist/shared/constants.js +6 -0
- package/dist/shared/logcat-capture.d.ts +15 -0
- package/dist/shared/logcat-capture.js +118 -0
- package/dist/shared/transport.d.ts +9 -6
- package/dist/web/bin.js +45 -30
- package/dist/web/public/index.html +771 -726
- package/dist/web/server.d.ts +27 -0
- package/dist/web/server.js +549 -28
- package/dist/web/spec.d.ts +83 -0
- package/dist/web/spec.js +135 -0
- package/package.json +2 -1
package/dist/shared/android.js
CHANGED
|
@@ -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
|
-
*
|
|
65
|
-
* Returns "0" for primary user,
|
|
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
|
|
81
|
-
const
|
|
82
|
-
return
|
|
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
|
-
|
|
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)" : `
|
|
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;
|
package/dist/shared/constants.js
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
38
|
+
catch {
|
|
39
|
+
// Browser open is best-effort
|
|
23
40
|
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
process.on("SIGTERM", shutdown);
|
|
50
|
+
startWebServer();
|