@slack/radar-mcp 1.1.2 → 1.3.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 +97 -2
- package/dist/mcp/snapshot.d.ts +63 -0
- package/dist/mcp/snapshot.js +301 -0
- package/dist/mcp/tools.js +82 -0
- package/dist/shared/android.d.ts +1 -0
- package/dist/shared/android.js +30 -0
- package/dist/shared/logcat-capture.d.ts +88 -0
- package/dist/shared/logcat-capture.js +414 -0
- package/dist/shared/transport.d.ts +35 -0
- package/package.json +1 -1
package/dist/mcp/index.js
CHANGED
|
@@ -6,7 +6,9 @@ import fs from "fs";
|
|
|
6
6
|
import http from "http";
|
|
7
7
|
import { AndroidTransport } from "../shared/android.js";
|
|
8
8
|
import { RADAR_HOST, RADAR_PORT } from "../shared/constants.js";
|
|
9
|
+
import { stopCapture } from "../shared/logcat-capture.js";
|
|
9
10
|
import { connectStream, destroyStream, waitForEvents, clearLocalBuffers, isStreamConnected, localNetworkBuffer, localRtmBuffer, setTransport, } from "../shared/stream.js";
|
|
11
|
+
import { buildBufferPath, createSnapshot } from "./snapshot.js";
|
|
10
12
|
import { TOOL_DEFINITIONS, SERVER_INSTRUCTIONS } from "./tools.js";
|
|
11
13
|
// --- Platform selection ---
|
|
12
14
|
function createTransport() {
|
|
@@ -135,6 +137,11 @@ function handleSessionDisable() {
|
|
|
135
137
|
sessionEnabled = false;
|
|
136
138
|
destroyStream();
|
|
137
139
|
clearLocalBuffers();
|
|
140
|
+
// Explicit disable stops the logcat capture immediately. Without this, the
|
|
141
|
+
// capture would linger up to the capture-idle timeout (15 min) after a
|
|
142
|
+
// deliberate user action saying "I am done", which is surprising and
|
|
143
|
+
// wastes disk + an adb logcat child.
|
|
144
|
+
stopCapture();
|
|
138
145
|
return textResult({
|
|
139
146
|
enabled: false,
|
|
140
147
|
note: "Radar disabled. Use /radar-enable to reconnect.",
|
|
@@ -171,10 +178,89 @@ function handleGetAppState() {
|
|
|
171
178
|
return textResult({ error: `App state query failed: ${e.message}` }, true);
|
|
172
179
|
}
|
|
173
180
|
}
|
|
181
|
+
function handleListProfiles() {
|
|
182
|
+
try {
|
|
183
|
+
// Narrow to implementations that expose `listProfiles`; it is optional on `DeviceTransport`.
|
|
184
|
+
const maybe = device;
|
|
185
|
+
if (typeof maybe.listProfiles !== "function") {
|
|
186
|
+
return textResult({ error: `Profile listing not supported on platform ${device.platform}` }, true);
|
|
187
|
+
}
|
|
188
|
+
const profiles = maybe.listProfiles();
|
|
189
|
+
return textResult({
|
|
190
|
+
profiles,
|
|
191
|
+
active_user_id: device.getActiveUserId(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return textResult({ error: `Profile list failed: ${e.message}` }, true);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function handleActivateForUser(args) {
|
|
199
|
+
const maybe = device;
|
|
200
|
+
if (typeof maybe.activateForUser !== "function") {
|
|
201
|
+
return textResult({ error: `Profile switching not supported on platform ${device.platform}` }, true);
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const timeout = args?.timeout_minutes || device.getTimeoutMinutes() || 5;
|
|
205
|
+
maybe.activateForUser(args.user_id, timeout);
|
|
206
|
+
// Restart the SSE stream so network/RTM/clog events from the newly
|
|
207
|
+
// activated profile start flowing again.
|
|
208
|
+
destroyStream();
|
|
209
|
+
connectStream();
|
|
210
|
+
// Give the newly spawned adb logcat child a moment to emit its first
|
|
211
|
+
// lines to the capture file. Matches the warmup delay used by
|
|
212
|
+
// handleSessionEnable so an immediate get_recent_logs call does not
|
|
213
|
+
// read an empty file.
|
|
214
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
215
|
+
return textResult({
|
|
216
|
+
user_id: args.user_id,
|
|
217
|
+
timeout_minutes: timeout,
|
|
218
|
+
capture_file: device.getCaptureFilePath?.() ?? null,
|
|
219
|
+
note: "Profile activated. Log capture restarted on a new file; old capture preserved for 7 days.",
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
return textResult({ error: `Profile activation failed: ${e.message}` }, true);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function handleCaptureSnapshot(args) {
|
|
227
|
+
try {
|
|
228
|
+
// Adapt `callRadar` (device HTTP + retry + session gate) into the
|
|
229
|
+
// simple `fetchBuffer(kind)` contract `createSnapshot` expects. Any
|
|
230
|
+
// `res.error` from callRadar is rethrown so the composer's per-buffer
|
|
231
|
+
// try/catch can record it in `errors[]` without bringing down the
|
|
232
|
+
// whole snapshot.
|
|
233
|
+
const fetchBuffer = async (kind) => {
|
|
234
|
+
const res = await callRadar(buildBufferPath(kind));
|
|
235
|
+
if (res.error)
|
|
236
|
+
throw new Error(res.error);
|
|
237
|
+
return res;
|
|
238
|
+
};
|
|
239
|
+
const result = await createSnapshot(device, fetchBuffer, {
|
|
240
|
+
includeLogs: args?.include_logs !== false,
|
|
241
|
+
includeScreenshot: args?.include_screenshot !== false,
|
|
242
|
+
logTailLines: args?.log_tail_lines,
|
|
243
|
+
});
|
|
244
|
+
return textResult(result, result.errors.length > 0 && result.files.length === 0);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
return textResult({ error: `Snapshot failed: ${e.message}` }, true);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
174
250
|
function handleGetRecentLogs(args) {
|
|
175
251
|
try {
|
|
176
252
|
const lines = device.getRecentLogs(args.tag, args.limit || 50, args.grep);
|
|
177
|
-
|
|
253
|
+
// Surface the capture file path so the model knows which on-disk file
|
|
254
|
+
// produced these lines. Helps avoid cross-session staleness (e.g. the
|
|
255
|
+
// model remembering an earlier profile's logs after a profile switch).
|
|
256
|
+
const sourceFile = device.getCaptureFilePath?.() ?? null;
|
|
257
|
+
return textResult({
|
|
258
|
+
tag: args.tag,
|
|
259
|
+
lines,
|
|
260
|
+
count: lines.length,
|
|
261
|
+
source_file: sourceFile,
|
|
262
|
+
source: sourceFile ? "capture" : "adb_logcat_d",
|
|
263
|
+
});
|
|
178
264
|
}
|
|
179
265
|
catch (e) {
|
|
180
266
|
return textResult({ error: `Log query failed: ${e.message}`, lines: [] }, true);
|
|
@@ -293,7 +379,7 @@ function buildApiPath(name, args) {
|
|
|
293
379
|
}
|
|
294
380
|
}
|
|
295
381
|
// --- MCP Server ---
|
|
296
|
-
const server = new Server({ name: "slack-radar", version: "1.
|
|
382
|
+
const server = new Server({ name: "slack-radar", version: "1.3.0" }, { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS });
|
|
297
383
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
298
384
|
tools: TOOL_DEFINITIONS,
|
|
299
385
|
}));
|
|
@@ -317,9 +403,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
|
|
|
317
403
|
if (name === "get_app_state") {
|
|
318
404
|
return handleGetAppState();
|
|
319
405
|
}
|
|
406
|
+
if (name === "list_profiles") {
|
|
407
|
+
return handleListProfiles();
|
|
408
|
+
}
|
|
409
|
+
if (name === "activate_for_user") {
|
|
410
|
+
return handleActivateForUser(toolArgs);
|
|
411
|
+
}
|
|
320
412
|
if (name === "get_recent_logs") {
|
|
321
413
|
return handleGetRecentLogs(toolArgs);
|
|
322
414
|
}
|
|
415
|
+
if (name === "capture_snapshot") {
|
|
416
|
+
return handleCaptureSnapshot(toolArgs);
|
|
417
|
+
}
|
|
323
418
|
if (name === "screenshot") {
|
|
324
419
|
return handleScreenshot(toolArgs);
|
|
325
420
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { DeviceTransport } from "../shared/transport.js";
|
|
2
|
+
export interface SnapshotOptions {
|
|
3
|
+
includeLogs?: boolean;
|
|
4
|
+
includeScreenshot?: boolean;
|
|
5
|
+
logTailLines?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SnapshotResult {
|
|
8
|
+
path: string;
|
|
9
|
+
files: string[];
|
|
10
|
+
zip_path: string | null;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
counts: {
|
|
13
|
+
network: number;
|
|
14
|
+
rtm: number;
|
|
15
|
+
clogs: number;
|
|
16
|
+
log_lines: number;
|
|
17
|
+
log_lines_truncated: boolean;
|
|
18
|
+
screenshot: boolean;
|
|
19
|
+
};
|
|
20
|
+
fetched: {
|
|
21
|
+
network: boolean;
|
|
22
|
+
rtm: boolean;
|
|
23
|
+
clogs: boolean;
|
|
24
|
+
};
|
|
25
|
+
errors: string[];
|
|
26
|
+
privacy_notice: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the output directory for a snapshot. Exported for tests.
|
|
30
|
+
*/
|
|
31
|
+
export declare function buildSnapshotDir(now?: number, root?: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Build the device-side URL for a given buffer kind. Extracted so the
|
|
34
|
+
* mapping is testable without standing up a device. `limit=1000` is
|
|
35
|
+
* intentionally larger than point-query defaults — snapshots are bulk
|
|
36
|
+
* exports and the device-side ring buffers cap their own responses.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildBufferPath(kind: "network" | "rtm" | "clogs"): string;
|
|
39
|
+
export interface LogTailResult {
|
|
40
|
+
text: string;
|
|
41
|
+
truncated: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Take the last N lines from the active capture file. Returns an empty
|
|
45
|
+
* result if no capture is active or the file is unreadable. Bounded read
|
|
46
|
+
* so we do not load a 100 MB capture into memory.
|
|
47
|
+
*
|
|
48
|
+
* `truncated` is true when the 1 MB file-read cap was the limiting factor
|
|
49
|
+
* (i.e. the user asked for more lines than fit), so the caller can tell
|
|
50
|
+
* the user they got fewer lines than requested.
|
|
51
|
+
*/
|
|
52
|
+
export declare function readRecentLogTail(requestedLimit?: number): LogTailResult;
|
|
53
|
+
/**
|
|
54
|
+
* Write a snapshot bundle to disk. The caller supplies `fetchBuffer` (a
|
|
55
|
+
* callback that hits the device's HTTP endpoint for a given buffer name)
|
|
56
|
+
* so this module does not need to know how `radarRequest` works — easier
|
|
57
|
+
* to test and keeps the device-HTTP plumbing in `index.ts`.
|
|
58
|
+
*
|
|
59
|
+
* On partial failure (e.g. screenshot fails because ADB is flaky), the
|
|
60
|
+
* snapshot still lands on disk with whatever succeeded; failures are
|
|
61
|
+
* collected in `result.errors` so the model can tell the user.
|
|
62
|
+
*/
|
|
63
|
+
export declare function createSnapshot(device: DeviceTransport, fetchBuffer: (kind: "network" | "rtm" | "clogs") => Promise<unknown>, opts?: SnapshotOptions, now?: number, snapshotRoot?: string): Promise<SnapshotResult>;
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { spawnSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { getCaptureFilePath, readTailBytes } from "../shared/logcat-capture.js";
|
|
6
|
+
/**
|
|
7
|
+
* `capture_snapshot` composer: bundles everything Radar has seen into a
|
|
8
|
+
* single on-disk artifact so a user can attach it to a Jira, drop it in a
|
|
9
|
+
* Slack thread, or share it with a teammate without copying individual
|
|
10
|
+
* tool outputs.
|
|
11
|
+
*
|
|
12
|
+
* Snapshot contents:
|
|
13
|
+
* - `bundle.json` — device-side buffers (net + RTM + clogs) plus metadata
|
|
14
|
+
* - `screenshot.png` — current screen (optional)
|
|
15
|
+
* - `logcat.tail.log` — last N lines of the active capture file (optional)
|
|
16
|
+
*
|
|
17
|
+
* Written to `~/.slack-radar/snapshots/snapshot-<ts>/`. No cleanup: snapshots
|
|
18
|
+
* are user-triggered, small, and should persist for post-hoc inspection.
|
|
19
|
+
* If that becomes a disk-growth concern, add a TTL GC like `logcat-capture.ts`.
|
|
20
|
+
*/
|
|
21
|
+
const SNAPSHOT_DIR = path.join(os.homedir(), ".slack-radar", "snapshots");
|
|
22
|
+
const DEFAULT_LOG_TAIL_LINES = 500;
|
|
23
|
+
const MAX_LOG_TAIL_LINES = 10_000;
|
|
24
|
+
const DEFAULT_LOG_TAIL_BYTES = 1 * 1024 * 1024; // 1 MB bound on file read
|
|
25
|
+
// Restrictive permissions on the snapshot dir and its files. The bundle
|
|
26
|
+
// contains network request/response bodies (including auth headers),
|
|
27
|
+
// message content, and user IDs — readable only by the owning user.
|
|
28
|
+
const DIR_MODE = 0o700;
|
|
29
|
+
const FILE_MODE = 0o600;
|
|
30
|
+
const BUNDLE_README = `Slack Radar snapshot
|
|
31
|
+
====================
|
|
32
|
+
|
|
33
|
+
This directory is a point-in-time bundle of Radar state.
|
|
34
|
+
|
|
35
|
+
Files:
|
|
36
|
+
- bundle.json: device-side network/RTM/clog buffers, counts, and metadata.
|
|
37
|
+
- logcat.tail.log (optional): last N lines of the on-device logcat capture.
|
|
38
|
+
- screenshot.png (optional): current screen.
|
|
39
|
+
|
|
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.
|
|
44
|
+
|
|
45
|
+
To inspect: cat bundle.json | jq .
|
|
46
|
+
To share: zip -r snapshot.zip . then attach the zip.
|
|
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.";
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the output directory for a snapshot. Exported for tests.
|
|
51
|
+
*/
|
|
52
|
+
export function buildSnapshotDir(now = Date.now(), root = SNAPSHOT_DIR) {
|
|
53
|
+
return path.join(root, `snapshot-${now}`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Build the device-side URL for a given buffer kind. Extracted so the
|
|
57
|
+
* mapping is testable without standing up a device. `limit=1000` is
|
|
58
|
+
* intentionally larger than point-query defaults — snapshots are bulk
|
|
59
|
+
* exports and the device-side ring buffers cap their own responses.
|
|
60
|
+
*/
|
|
61
|
+
export function buildBufferPath(kind) {
|
|
62
|
+
return `/api/${kind}?limit=1000`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Take the last N lines from the active capture file. Returns an empty
|
|
66
|
+
* result if no capture is active or the file is unreadable. Bounded read
|
|
67
|
+
* so we do not load a 100 MB capture into memory.
|
|
68
|
+
*
|
|
69
|
+
* `truncated` is true when the 1 MB file-read cap was the limiting factor
|
|
70
|
+
* (i.e. the user asked for more lines than fit), so the caller can tell
|
|
71
|
+
* the user they got fewer lines than requested.
|
|
72
|
+
*/
|
|
73
|
+
export function readRecentLogTail(requestedLimit = DEFAULT_LOG_TAIL_LINES) {
|
|
74
|
+
// Defensive clamp — caller validates at the handler boundary too, but
|
|
75
|
+
// a direct caller cannot accidentally get "entire file" by passing NaN.
|
|
76
|
+
const limit = Number.isFinite(requestedLimit) &&
|
|
77
|
+
requestedLimit > 0 &&
|
|
78
|
+
requestedLimit <= MAX_LOG_TAIL_LINES
|
|
79
|
+
? Math.floor(requestedLimit)
|
|
80
|
+
: DEFAULT_LOG_TAIL_LINES;
|
|
81
|
+
const filePath = getCaptureFilePath();
|
|
82
|
+
if (!filePath)
|
|
83
|
+
return { text: "", truncated: false };
|
|
84
|
+
const raw = readTailBytes(filePath, DEFAULT_LOG_TAIL_BYTES);
|
|
85
|
+
if (!raw)
|
|
86
|
+
return { text: "", truncated: false };
|
|
87
|
+
const lines = raw.split("\n");
|
|
88
|
+
// If the raw read hit the 1 MB byte cap, the FIRST line is likely
|
|
89
|
+
// partial — readTailBytes drops it — and the user got whatever fits.
|
|
90
|
+
// "Truncated" means "user asked for more lines than the byte cap can
|
|
91
|
+
// contain AND we delivered fewer lines than requested."
|
|
92
|
+
const truncated = lines.length < limit && raw.length >= DEFAULT_LOG_TAIL_BYTES;
|
|
93
|
+
const tail = lines.slice(Math.max(0, lines.length - limit));
|
|
94
|
+
return { text: tail.join("\n"), truncated };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Write a snapshot bundle to disk. The caller supplies `fetchBuffer` (a
|
|
98
|
+
* callback that hits the device's HTTP endpoint for a given buffer name)
|
|
99
|
+
* so this module does not need to know how `radarRequest` works — easier
|
|
100
|
+
* to test and keeps the device-HTTP plumbing in `index.ts`.
|
|
101
|
+
*
|
|
102
|
+
* On partial failure (e.g. screenshot fails because ADB is flaky), the
|
|
103
|
+
* snapshot still lands on disk with whatever succeeded; failures are
|
|
104
|
+
* collected in `result.errors` so the model can tell the user.
|
|
105
|
+
*/
|
|
106
|
+
export async function createSnapshot(device, fetchBuffer, opts = {}, now = Date.now(), snapshotRoot = SNAPSHOT_DIR) {
|
|
107
|
+
const includeLogs = opts.includeLogs !== false;
|
|
108
|
+
const includeScreenshot = opts.includeScreenshot !== false;
|
|
109
|
+
// Clamp log_tail_lines at the composer boundary. The handler and schema
|
|
110
|
+
// validate too; this is defense-in-depth so no downstream consumer sees
|
|
111
|
+
// a pathological value.
|
|
112
|
+
const logTailLines = typeof opts.logTailLines === "number" &&
|
|
113
|
+
Number.isFinite(opts.logTailLines) &&
|
|
114
|
+
opts.logTailLines > 0 &&
|
|
115
|
+
opts.logTailLines <= MAX_LOG_TAIL_LINES
|
|
116
|
+
? Math.floor(opts.logTailLines)
|
|
117
|
+
: DEFAULT_LOG_TAIL_LINES;
|
|
118
|
+
const dir = buildSnapshotDir(now, snapshotRoot);
|
|
119
|
+
fs.mkdirSync(dir, { recursive: true, mode: DIR_MODE });
|
|
120
|
+
// `mkdirSync({ recursive: true, mode })` only applies the mode to
|
|
121
|
+
// directories it CREATES. If `~/.slack-radar/` or `.../snapshots/`
|
|
122
|
+
// already existed (e.g. created by a pre-R1 build or by the logcat
|
|
123
|
+
// capture module with default umask → 0o755), they keep old perms.
|
|
124
|
+
// Tighten them to 0o700 on every snapshot so the parent tree cannot
|
|
125
|
+
// leak directory-listing on a shared host.
|
|
126
|
+
for (const p of [snapshotRoot, path.dirname(snapshotRoot)]) {
|
|
127
|
+
try {
|
|
128
|
+
if (fs.existsSync(p))
|
|
129
|
+
fs.chmodSync(p, DIR_MODE);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Best effort; not fatal.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const files = [];
|
|
136
|
+
const errors = [];
|
|
137
|
+
let network = null;
|
|
138
|
+
let rtm = null;
|
|
139
|
+
let clogs = null;
|
|
140
|
+
const fetched = { network: false, rtm: false, clogs: false };
|
|
141
|
+
// Device-side buffers. Failures are individually caught so one failing
|
|
142
|
+
// buffer does not nuke the whole snapshot. `fetched` distinguishes
|
|
143
|
+
// "empty because the buffer was empty" from "null because the fetch
|
|
144
|
+
// failed" — counts alone cannot tell these apart.
|
|
145
|
+
try {
|
|
146
|
+
network = await fetchBuffer("network");
|
|
147
|
+
fetched.network = true;
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
errors.push(`network buffer: ${e.message}`);
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
rtm = await fetchBuffer("rtm");
|
|
154
|
+
fetched.rtm = true;
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
errors.push(`rtm buffer: ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
clogs = await fetchBuffer("clogs");
|
|
161
|
+
fetched.clogs = true;
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
errors.push(`clog buffer: ${e.message}`);
|
|
165
|
+
}
|
|
166
|
+
// Logcat tail.
|
|
167
|
+
let logTail = { text: "", truncated: false };
|
|
168
|
+
if (includeLogs) {
|
|
169
|
+
try {
|
|
170
|
+
logTail = readRecentLogTail(logTailLines);
|
|
171
|
+
if (logTail.text) {
|
|
172
|
+
const logFile = path.join(dir, "logcat.tail.log");
|
|
173
|
+
fs.writeFileSync(logFile, logTail.text, { mode: FILE_MODE });
|
|
174
|
+
files.push(logFile);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
errors.push(`logcat tail: ${e.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Screenshot. 720px default is plenty for bug reports; full-res is
|
|
182
|
+
// 5-8x larger on disk and rarely adds forensic value.
|
|
183
|
+
let screenshotCaptured = false;
|
|
184
|
+
if (includeScreenshot) {
|
|
185
|
+
try {
|
|
186
|
+
const tmp = device.screenshot(false);
|
|
187
|
+
const dest = path.join(dir, "screenshot.png");
|
|
188
|
+
fs.copyFileSync(tmp, dest);
|
|
189
|
+
// copyFileSync preserves source mode by default on POSIX; chmod to
|
|
190
|
+
// 0600 so the copy is owner-only regardless of source permissions.
|
|
191
|
+
try {
|
|
192
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
// Non-fatal (worst case: screenshot has source mode, typically
|
|
196
|
+
// 0644 on macOS /tmp pulls) but user should know — a bundle they
|
|
197
|
+
// share externally has a world-readable PNG file if this path
|
|
198
|
+
// hits.
|
|
199
|
+
errors.push(`screenshot chmod failed: ${e.message} (file keeps source mode)`);
|
|
200
|
+
}
|
|
201
|
+
files.push(dest);
|
|
202
|
+
screenshotCaptured = true;
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
errors.push(`screenshot: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// README. One-paragraph explainer so a receiver who opens the dir for
|
|
209
|
+
// the first time knows what each file is and what privacy exposure it
|
|
210
|
+
// carries.
|
|
211
|
+
try {
|
|
212
|
+
const readmeFile = path.join(dir, "README.txt");
|
|
213
|
+
fs.writeFileSync(readmeFile, BUNDLE_README, { mode: FILE_MODE });
|
|
214
|
+
files.push(readmeFile);
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
errors.push(`README: ${e.message}`);
|
|
218
|
+
}
|
|
219
|
+
// Bundle JSON. Written last so counts reflect everything captured.
|
|
220
|
+
const counts = {
|
|
221
|
+
network: countItems(network),
|
|
222
|
+
rtm: countItems(rtm),
|
|
223
|
+
clogs: countItems(clogs),
|
|
224
|
+
log_lines: logTail.text
|
|
225
|
+
? logTail.text.split("\n").filter(Boolean).length
|
|
226
|
+
: 0,
|
|
227
|
+
log_lines_truncated: logTail.truncated,
|
|
228
|
+
screenshot: screenshotCaptured,
|
|
229
|
+
};
|
|
230
|
+
const bundle = {
|
|
231
|
+
version: 1,
|
|
232
|
+
timestamp: now,
|
|
233
|
+
platform: device.platform,
|
|
234
|
+
active_user_id: device.getActiveUserId(),
|
|
235
|
+
capture_file: getCaptureFilePath(),
|
|
236
|
+
buffers: { network, rtm, clogs },
|
|
237
|
+
counts,
|
|
238
|
+
fetched,
|
|
239
|
+
errors,
|
|
240
|
+
privacy_notice: PRIVACY_NOTICE,
|
|
241
|
+
};
|
|
242
|
+
const bundleFile = path.join(dir, "bundle.json");
|
|
243
|
+
fs.writeFileSync(bundleFile, JSON.stringify(bundle, null, 2), {
|
|
244
|
+
mode: FILE_MODE,
|
|
245
|
+
});
|
|
246
|
+
files.unshift(bundleFile);
|
|
247
|
+
// Auto-zip: collapse the 4-file directory into a single shareable
|
|
248
|
+
// artifact. Users universally want to attach ONE file to Jira or drop
|
|
249
|
+
// ONE file in Slack, not drag a directory. Best-effort: if `zip` is
|
|
250
|
+
// missing on the user's PATH (rare — ships with macOS and most Linux
|
|
251
|
+
// distros) we just record the failure and the user can zip manually.
|
|
252
|
+
let zipPath = null;
|
|
253
|
+
const zipCandidate = `${dir}.zip`;
|
|
254
|
+
try {
|
|
255
|
+
const zipResult = spawnSync("zip", ["-rq", zipCandidate, "."], {
|
|
256
|
+
cwd: dir,
|
|
257
|
+
timeout: 10000,
|
|
258
|
+
});
|
|
259
|
+
if (zipResult.status === 0) {
|
|
260
|
+
try {
|
|
261
|
+
fs.chmodSync(zipCandidate, FILE_MODE);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Leave mode as-is; already a zip copy of owner-readable files.
|
|
265
|
+
}
|
|
266
|
+
zipPath = zipCandidate;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
errors.push(`zip failed with status ${zipResult.status ?? "null"}${zipResult.stderr ? ": " + zipResult.stderr.toString().trim() : ""}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
errors.push(`zip not available: ${e.message}`);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
path: dir,
|
|
277
|
+
files,
|
|
278
|
+
zip_path: zipPath,
|
|
279
|
+
timestamp: now,
|
|
280
|
+
counts,
|
|
281
|
+
fetched,
|
|
282
|
+
errors,
|
|
283
|
+
privacy_notice: PRIVACY_NOTICE,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 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.
|
|
290
|
+
*/
|
|
291
|
+
function countItems(body) {
|
|
292
|
+
if (!body || typeof body !== "object")
|
|
293
|
+
return 0;
|
|
294
|
+
const b = body;
|
|
295
|
+
for (const key of ["calls", "events", "clogs", "items"]) {
|
|
296
|
+
const v = b[key];
|
|
297
|
+
if (Array.isArray(v))
|
|
298
|
+
return v.length;
|
|
299
|
+
}
|
|
300
|
+
return 0;
|
|
301
|
+
}
|
package/dist/mcp/tools.js
CHANGED
|
@@ -22,6 +22,7 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
22
22
|
'- "what API call was that" -> get_recent_network_calls then get_network_call_detail',
|
|
23
23
|
'- "did I get X" / "did X happen" -> search(query="X") across ALL buffers, not just one',
|
|
24
24
|
'- "check the debug logs" -> get_recent_logs(tag="...")',
|
|
25
|
+
'- "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',
|
|
25
26
|
"",
|
|
26
27
|
"Key behaviors:",
|
|
27
28
|
"- Network/RTM buffers clear on shutdown. Clogs persist from app start.",
|
|
@@ -32,6 +33,38 @@ export const SERVER_INSTRUCTIONS = [
|
|
|
32
33
|
"- Use ping(timeout_minutes=N) to extend sessions. Monitor remaining_minutes proactively.",
|
|
33
34
|
"- Screenshots default to 720px. Use full_res=true only when detail matters.",
|
|
34
35
|
"- Setup: /slack-radar-setup",
|
|
36
|
+
"- Manual enable (no skill): adb shell am broadcast -a slack.debug.ENABLE_RADAR --ei timeout_minutes 5 -p com.Slack.internal.debug",
|
|
37
|
+
" Then: adb forward tcp:8099 localabstract:slack-radar",
|
|
38
|
+
"",
|
|
39
|
+
"WEB DASHBOARD (/radar-ui):",
|
|
40
|
+
"A browser-based dashboard (http://localhost:8100) shows the same live device data in a",
|
|
41
|
+
"visual UI. Launched via the /radar-ui skill, which wraps `npx -p @slack/radar-mcp slack-radar-web`.",
|
|
42
|
+
"",
|
|
43
|
+
"When to suggest /radar-ui (mention it ONCE per thread, do not auto-invoke):",
|
|
44
|
+
'- User says "watch", "monitor", "let me see", "show me live" about device traffic',
|
|
45
|
+
"- User wants to share traffic with a colleague, pair, or teammate",
|
|
46
|
+
"- User is doing a multi-minute investigation and the transcript is getting noisy",
|
|
47
|
+
'- User explicitly asks about a GUI, UI, browser view, or dashboard',
|
|
48
|
+
"",
|
|
49
|
+
"DO NOT invoke /radar-ui yourself. Opening a browser window is a user-visible action.",
|
|
50
|
+
"DO NOT suggest /radar-ui for single-query debugging or when the user is happy reading",
|
|
51
|
+
"results in the transcript. Suggest it when visual inspection adds real value.",
|
|
52
|
+
"",
|
|
53
|
+
"CAPTURE_SNAPSHOT OUTPUT:",
|
|
54
|
+
"When capture_snapshot returns, tell the user:",
|
|
55
|
+
" 1. The result.zip_path if non-null (the single shareable file). Fall back to",
|
|
56
|
+
" result.path (a directory) if zip_path is null.",
|
|
57
|
+
" 2. A one-line summary of counts (network/rtm/clogs/log_lines/screenshot).",
|
|
58
|
+
" 3. Print this EXACT sentence as the privacy line, verbatim — no paraphrase, no",
|
|
59
|
+
" compression, no skipping even under time pressure:",
|
|
60
|
+
' "Privacy: bundle.json contains raw API request/response bodies (including auth headers), RTM message content, user IDs, and clog payloads. Review before sharing externally."',
|
|
61
|
+
" 4. If result.log_lines_truncated is true, say: 'you got fewer log lines than",
|
|
62
|
+
" requested because the 1 MB read cap was the limiting factor.'",
|
|
63
|
+
" 5. If the user's original ask was 'file this as a bug' / 'attach to ticket' /",
|
|
64
|
+
" similar, offer to attach result.zip_path via the Jira MCP or Slack MCP as",
|
|
65
|
+
" the next step. Do NOT invoke the attach without asking. Just offer.",
|
|
66
|
+
" 6. If result.errors is non-empty, enumerate which parts failed so the user",
|
|
67
|
+
" knows what is missing before they ship the bundle.",
|
|
35
68
|
].join("\n");
|
|
36
69
|
export const TOOL_DEFINITIONS = [
|
|
37
70
|
{
|
|
@@ -171,6 +204,55 @@ export const TOOL_DEFINITIONS = [
|
|
|
171
204
|
},
|
|
172
205
|
annotations: { readOnlyHint: true },
|
|
173
206
|
},
|
|
207
|
+
{
|
|
208
|
+
name: "list_profiles",
|
|
209
|
+
description: "List Android user profiles that have the Slack debug build running. Use this before activate_for_user when the user asks to switch profiles (personal vs work) or when logs/app state seem to point at the wrong profile. Returns each profile's userId (e.g. '0' for personal, '10' for work) and a human label.",
|
|
210
|
+
inputSchema: { type: "object", properties: {} },
|
|
211
|
+
annotations: { readOnlyHint: true },
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
name: "activate_for_user",
|
|
215
|
+
description: "Activate Slack Radar on a specific Android user profile. Use after list_profiles to switch between personal and work profile targets. Switching profiles restarts log capture with a new session-scoped file and reconnects the SSE event stream so events come from the newly activated profile; old captures remain on disk for up to 7 days. Call this when already connected and only the target profile needs to change.",
|
|
216
|
+
inputSchema: {
|
|
217
|
+
type: "object",
|
|
218
|
+
properties: {
|
|
219
|
+
user_id: {
|
|
220
|
+
type: "string",
|
|
221
|
+
description: "Android user ID from list_profiles (e.g. '0' for personal, '10' for work).",
|
|
222
|
+
},
|
|
223
|
+
timeout_minutes: {
|
|
224
|
+
type: "number",
|
|
225
|
+
description: "Auto-shutdown timeout in minutes (default: 5).",
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
required: ["user_id"],
|
|
229
|
+
},
|
|
230
|
+
annotations: {},
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: "capture_snapshot",
|
|
234
|
+
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.",
|
|
235
|
+
inputSchema: {
|
|
236
|
+
type: "object",
|
|
237
|
+
properties: {
|
|
238
|
+
include_logs: {
|
|
239
|
+
type: "boolean",
|
|
240
|
+
description: "Include last N lines of the active logcat capture (default: true). No-op when no capture is active.",
|
|
241
|
+
},
|
|
242
|
+
include_screenshot: {
|
|
243
|
+
type: "boolean",
|
|
244
|
+
description: "Include a current screenshot (default: true). Requires a connected device.",
|
|
245
|
+
},
|
|
246
|
+
log_tail_lines: {
|
|
247
|
+
type: "number",
|
|
248
|
+
description: "How many trailing lines of the capture file to include (default: 500, min: 1, max: 10000). Truncation flagged in counts.log_lines_truncated.",
|
|
249
|
+
minimum: 1,
|
|
250
|
+
maximum: 10000,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
annotations: { readOnlyHint: false },
|
|
255
|
+
},
|
|
174
256
|
{
|
|
175
257
|
name: "screenshot",
|
|
176
258
|
description: "Capture a screenshot from the connected Android device.",
|
package/dist/shared/android.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export declare class AndroidTransport implements DeviceTransport {
|
|
|
31
31
|
isForwarded(): boolean;
|
|
32
32
|
getTimeoutMinutes(): number;
|
|
33
33
|
getActiveUserId(): string | null;
|
|
34
|
+
getCaptureFilePath(): string | null;
|
|
34
35
|
screenshot(fullRes: boolean): string;
|
|
35
36
|
recordScreen(durationS: number): string;
|
|
36
37
|
getAppState(): string;
|
package/dist/shared/android.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execSync } from "child_process";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import os from "os";
|
|
4
4
|
import path from "path";
|
|
5
|
+
import { getCaptureFilePath, isCapturing, readRecentLogs, registerCleanup, rotateCaptureIfNeeded, startCapture, stopCapture, } from "./logcat-capture.js";
|
|
5
6
|
const RADAR_PORT = 8099;
|
|
6
7
|
const SOCKET_NAME = "slack-radar";
|
|
7
8
|
const ENABLE_ACTION = "slack.debug.ENABLE_RADAR";
|
|
@@ -122,6 +123,10 @@ export class AndroidTransport {
|
|
|
122
123
|
execSync(`${ADB} shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
|
|
123
124
|
this.currentTimeoutMinutes = timeout;
|
|
124
125
|
this.radarEnabled = true;
|
|
126
|
+
// Start streaming logcat to a session-scoped file so reads are not
|
|
127
|
+
// subject to the device ring buffer rotating under load.
|
|
128
|
+
registerCleanup();
|
|
129
|
+
startCapture(ADB);
|
|
125
130
|
}
|
|
126
131
|
catch {
|
|
127
132
|
// Broadcast failed — device may not be connected
|
|
@@ -132,6 +137,7 @@ export class AndroidTransport {
|
|
|
132
137
|
this.detectedUserId = null;
|
|
133
138
|
this.radarEnabled = false;
|
|
134
139
|
this.lastForwardError = null;
|
|
140
|
+
// Capture has its own idle timer; not stopped on activation reset (see logcat-capture.ts).
|
|
135
141
|
}
|
|
136
142
|
isForwarded() {
|
|
137
143
|
return this.portForwarded;
|
|
@@ -142,6 +148,9 @@ export class AndroidTransport {
|
|
|
142
148
|
getActiveUserId() {
|
|
143
149
|
return this.detectedUserId;
|
|
144
150
|
}
|
|
151
|
+
getCaptureFilePath() {
|
|
152
|
+
return getCaptureFilePath();
|
|
153
|
+
}
|
|
145
154
|
screenshot(fullRes) {
|
|
146
155
|
if (!ADB)
|
|
147
156
|
throw new Error("adb not found on PATH or common SDK locations");
|
|
@@ -179,6 +188,19 @@ export class AndroidTransport {
|
|
|
179
188
|
getRecentLogs(tag, limit, grep) {
|
|
180
189
|
if (!ADB)
|
|
181
190
|
throw new Error("adb not found on PATH or common SDK locations");
|
|
191
|
+
// Preferred path: read from the session capture file so lines cannot
|
|
192
|
+
// have been evicted from the device ring buffer between enable and now.
|
|
193
|
+
if (isCapturing()) {
|
|
194
|
+
// Opportunistic rotation. Stop+rename+restart when the file grows
|
|
195
|
+
// past the threshold so the child never writes past a truncated EOF.
|
|
196
|
+
// Bounded to the read cadence; no extra timer.
|
|
197
|
+
rotateCaptureIfNeeded(ADB);
|
|
198
|
+
const lines = readRecentLogs(tag, limit, grep);
|
|
199
|
+
if (lines !== null)
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
// Fallback: device ring buffer snapshot. Used when capture is not running
|
|
203
|
+
// (activate() was skipped, child died, or the file was deleted).
|
|
182
204
|
let cmd = `${ADB} logcat -d | grep -F "${tag}" | tail -${limit}`;
|
|
183
205
|
if (grep) {
|
|
184
206
|
cmd = `${ADB} logcat -d | grep -F "${tag}" | grep -i "${grep.replace(/"/g, '\\"')}" | tail -${limit}`;
|
|
@@ -222,10 +244,18 @@ export class AndroidTransport {
|
|
|
222
244
|
if (!ADB)
|
|
223
245
|
throw new Error("adb not found on PATH or common SDK locations");
|
|
224
246
|
const timeout = timeoutMinutes || this.currentTimeoutMinutes;
|
|
247
|
+
const profileChanged = this.detectedUserId !== null && this.detectedUserId !== userId;
|
|
225
248
|
execSync(`${ADB} shell am broadcast --user ${userId} -a ${ENABLE_ACTION} --ei timeout_minutes ${timeout}`, { timeout: 5000 });
|
|
226
249
|
this.detectedUserId = userId;
|
|
227
250
|
this.currentTimeoutMinutes = timeout;
|
|
228
251
|
this.radarEnabled = true;
|
|
252
|
+
// Profile switch means the interesting scope changed; drop the old
|
|
253
|
+
// capture so the new session starts from a fresh file scoped to this
|
|
254
|
+
// profile's debug run.
|
|
255
|
+
if (profileChanged)
|
|
256
|
+
stopCapture();
|
|
257
|
+
registerCleanup();
|
|
258
|
+
startCapture(ADB);
|
|
229
259
|
}
|
|
230
260
|
detectDebugBuildUser() {
|
|
231
261
|
if (this.detectedUserId !== null)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ChildProcess } from "child_process";
|
|
2
|
+
export interface CaptureState {
|
|
3
|
+
child: ChildProcess | null;
|
|
4
|
+
filePath: string | null;
|
|
5
|
+
startedAt: number | null;
|
|
6
|
+
lastReadAt: number | null;
|
|
7
|
+
idleTimer: ReturnType<typeof setTimeout> | null;
|
|
8
|
+
captureIdleMs: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
|
|
12
|
+
* disk usage without losing recent forensic history. Extracted for test.
|
|
13
|
+
*/
|
|
14
|
+
export declare function gcOldCaptures(dir?: string, now?: number): number;
|
|
15
|
+
export declare function buildCaptureFilePath(now?: number): string;
|
|
16
|
+
export declare function isCapturing(): boolean;
|
|
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;
|
|
30
|
+
/**
|
|
31
|
+
* Start streaming logcat to a session-scoped file. Idempotent: a second call
|
|
32
|
+
* while the first capture is live is a no-op. If a prior capture's child is
|
|
33
|
+
* dead (crashed, killed), it is cleared first so this call starts fresh.
|
|
34
|
+
*/
|
|
35
|
+
export declare function startCapture(adbPath: string): string | null;
|
|
36
|
+
export declare function stopCapture(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Check whether the file at `filePath` exceeds ROTATION_BYTES.
|
|
39
|
+
*
|
|
40
|
+
* The caller is responsible for performing rotation — the old approach of
|
|
41
|
+
* truncating in-place was unsafe because the child `adb logcat` process
|
|
42
|
+
* still holds an open fd at its previous write offset; after a truncate
|
|
43
|
+
* the next write lands past the new EOF and produces a sparse file with a
|
|
44
|
+
* null-byte hole. See `rotateCaptureIfNeeded` for the safe rotation path:
|
|
45
|
+
* stop the child, rename the old file, start a new child with a fresh fd.
|
|
46
|
+
*
|
|
47
|
+
* Extracted so the size check is testable without running adb.
|
|
48
|
+
*/
|
|
49
|
+
export declare function needsRotation(filePath: string): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* If the active capture has grown past the rotation threshold, cleanly
|
|
52
|
+
* rotate it: stop the child (closing its fd), rename the old file with a
|
|
53
|
+
* `.rotated-<ts>` suffix so it remains readable by consumers for a while
|
|
54
|
+
* (GC reclaims it after the 7-day TTL), and start a fresh child writing
|
|
55
|
+
* to a new file.
|
|
56
|
+
*
|
|
57
|
+
* Only has effect if a capture is currently active. Returns the new file
|
|
58
|
+
* path (or null if no rotation happened or rotation failed).
|
|
59
|
+
*/
|
|
60
|
+
export declare function rotateCaptureIfNeeded(adbPath: string): string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Filter captured log lines by tag substring (logcat threadtime format puts
|
|
63
|
+
* the tag after the PID/TID columns), optional grep (case-insensitive), and
|
|
64
|
+
* tail to `limit` most-recent matches.
|
|
65
|
+
*
|
|
66
|
+
* Extracted so the pure logic can be tested without a running adb.
|
|
67
|
+
*/
|
|
68
|
+
export declare function filterLogLines(lines: string[], tag: string, limit: number, grep?: string): string[];
|
|
69
|
+
/**
|
|
70
|
+
* Read the last `maxBytes` of a file without loading the whole thing. Returns
|
|
71
|
+
* the tail as a UTF-8 string, dropping any partial first line so the result
|
|
72
|
+
* starts at a line boundary. Keeps memory bounded regardless of file size,
|
|
73
|
+
* which matters once the capture file grows (and once the web dashboard
|
|
74
|
+
* starts polling for logs in a later PR).
|
|
75
|
+
*
|
|
76
|
+
* Extracted for testability.
|
|
77
|
+
*/
|
|
78
|
+
export declare function readTailBytes(filePath: string, maxBytes: number): string;
|
|
79
|
+
/**
|
|
80
|
+
* Read recent captured lines matching tag/grep, tailed to `limit`. Returns
|
|
81
|
+
* null when no capture is active (caller falls back to `adb logcat -d`).
|
|
82
|
+
*
|
|
83
|
+
* Reads only the last DEFAULT_TAIL_BYTES of the capture file so cost stays
|
|
84
|
+
* bounded as the file grows. For typical logcat traffic this window covers
|
|
85
|
+
* several thousand lines, which safely exceeds practical `limit` values.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readRecentLogs(tag: string, limit: number, grep?: string): string[] | null;
|
|
88
|
+
export declare function registerCleanup(): void;
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
/**
|
|
6
|
+
* Streams `adb logcat` to a per-session file so log queries read from a
|
|
7
|
+
* local, non-rotating store instead of the device's ring buffer at query time.
|
|
8
|
+
*
|
|
9
|
+
* The device-side logcat buffer is ~256KB-1MB per buffer and rotates under
|
|
10
|
+
* load; a chatty session can overwrite lines a user wanted to inspect hours
|
|
11
|
+
* later. Capturing to a local file from session-enable onward preserves
|
|
12
|
+
* everything from the enable moment forward.
|
|
13
|
+
*
|
|
14
|
+
* Lifecycle: startCapture on activate, stopCapture on resetState/disable.
|
|
15
|
+
*/
|
|
16
|
+
const LOG_DIR = path.join(os.homedir(), ".slack-radar", "logs");
|
|
17
|
+
const ROTATION_BYTES = 100 * 1024 * 1024;
|
|
18
|
+
const ROTATION_KEEP_BYTES = 50 * 1024 * 1024;
|
|
19
|
+
const GC_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const DEFAULT_CAPTURE_IDLE_MS = 15 * 60 * 1000;
|
|
21
|
+
const state = {
|
|
22
|
+
child: null,
|
|
23
|
+
filePath: null,
|
|
24
|
+
startedAt: null,
|
|
25
|
+
lastReadAt: null,
|
|
26
|
+
idleTimer: null,
|
|
27
|
+
captureIdleMs: DEFAULT_CAPTURE_IDLE_MS,
|
|
28
|
+
};
|
|
29
|
+
function ensureLogDir() {
|
|
30
|
+
if (!fs.existsSync(LOG_DIR)) {
|
|
31
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Delete capture files older than GC_TTL_MS. Runs at session enable. Bounded
|
|
36
|
+
* disk usage without losing recent forensic history. Extracted for test.
|
|
37
|
+
*/
|
|
38
|
+
export function gcOldCaptures(dir = LOG_DIR, now = Date.now()) {
|
|
39
|
+
if (!fs.existsSync(dir))
|
|
40
|
+
return 0;
|
|
41
|
+
let removed = 0;
|
|
42
|
+
for (const name of fs.readdirSync(dir)) {
|
|
43
|
+
if (!name.startsWith("radar-") || !name.endsWith(".log"))
|
|
44
|
+
continue;
|
|
45
|
+
const full = path.join(dir, name);
|
|
46
|
+
try {
|
|
47
|
+
const stat = fs.statSync(full);
|
|
48
|
+
if (now - stat.mtimeMs > GC_TTL_MS) {
|
|
49
|
+
fs.unlinkSync(full);
|
|
50
|
+
removed += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// ignore; file may have vanished between readdir and stat
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return removed;
|
|
58
|
+
}
|
|
59
|
+
export function buildCaptureFilePath(now = Date.now()) {
|
|
60
|
+
return path.join(LOG_DIR, `radar-${now}.log`);
|
|
61
|
+
}
|
|
62
|
+
export function isCapturing() {
|
|
63
|
+
return state.child !== null && state.child.exitCode === null;
|
|
64
|
+
}
|
|
65
|
+
export function getCaptureFilePath() {
|
|
66
|
+
return state.filePath;
|
|
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
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Start streaming logcat to a session-scoped file. Idempotent: a second call
|
|
152
|
+
* while the first capture is live is a no-op. If a prior capture's child is
|
|
153
|
+
* dead (crashed, killed), it is cleared first so this call starts fresh.
|
|
154
|
+
*/
|
|
155
|
+
export function startCapture(adbPath) {
|
|
156
|
+
if (isCapturing())
|
|
157
|
+
return state.filePath;
|
|
158
|
+
// Stale child: clear before restarting.
|
|
159
|
+
if (state.child !== null)
|
|
160
|
+
stopCapture();
|
|
161
|
+
ensureLogDir();
|
|
162
|
+
gcOldCaptures();
|
|
163
|
+
const filePath = buildCaptureFilePath();
|
|
164
|
+
const out = fs.openSync(filePath, "a");
|
|
165
|
+
try {
|
|
166
|
+
const child = spawn(adbPath, ["logcat", "-v", "threadtime"], {
|
|
167
|
+
stdio: ["ignore", out, out],
|
|
168
|
+
detached: false,
|
|
169
|
+
});
|
|
170
|
+
child.on("exit", () => {
|
|
171
|
+
try {
|
|
172
|
+
fs.closeSync(out);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// already closed
|
|
176
|
+
}
|
|
177
|
+
if (state.child === child) {
|
|
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
|
+
}
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
child.on("error", () => {
|
|
187
|
+
try {
|
|
188
|
+
fs.closeSync(out);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// already closed
|
|
192
|
+
}
|
|
193
|
+
if (state.child === child) {
|
|
194
|
+
state.child = null;
|
|
195
|
+
state.filePath = null;
|
|
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
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
state.child = child;
|
|
207
|
+
state.filePath = filePath;
|
|
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();
|
|
214
|
+
// Child bring-up warmup is handled at the MCP handler boundary
|
|
215
|
+
// (handleSessionEnable / handleActivateForUser add a small async delay
|
|
216
|
+
// before returning). Doing it here would require either blocking the
|
|
217
|
+
// single-threaded MCP event loop with a busy-wait or making startCapture
|
|
218
|
+
// async, neither of which earns the complexity.
|
|
219
|
+
return filePath;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
try {
|
|
223
|
+
fs.closeSync(out);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// ignore
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export function stopCapture() {
|
|
232
|
+
const child = state.child;
|
|
233
|
+
const timer = state.idleTimer;
|
|
234
|
+
state.child = null;
|
|
235
|
+
state.filePath = null;
|
|
236
|
+
state.startedAt = null;
|
|
237
|
+
state.lastReadAt = null;
|
|
238
|
+
state.idleTimer = null;
|
|
239
|
+
if (timer)
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
if (child && child.exitCode === null) {
|
|
242
|
+
try {
|
|
243
|
+
child.kill("SIGTERM");
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// already dead
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Check whether the file at `filePath` exceeds ROTATION_BYTES.
|
|
252
|
+
*
|
|
253
|
+
* The caller is responsible for performing rotation — the old approach of
|
|
254
|
+
* truncating in-place was unsafe because the child `adb logcat` process
|
|
255
|
+
* still holds an open fd at its previous write offset; after a truncate
|
|
256
|
+
* the next write lands past the new EOF and produces a sparse file with a
|
|
257
|
+
* null-byte hole. See `rotateCaptureIfNeeded` for the safe rotation path:
|
|
258
|
+
* stop the child, rename the old file, start a new child with a fresh fd.
|
|
259
|
+
*
|
|
260
|
+
* Extracted so the size check is testable without running adb.
|
|
261
|
+
*/
|
|
262
|
+
export function needsRotation(filePath) {
|
|
263
|
+
try {
|
|
264
|
+
const stat = fs.statSync(filePath);
|
|
265
|
+
return stat.size > ROTATION_BYTES;
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* If the active capture has grown past the rotation threshold, cleanly
|
|
273
|
+
* rotate it: stop the child (closing its fd), rename the old file with a
|
|
274
|
+
* `.rotated-<ts>` suffix so it remains readable by consumers for a while
|
|
275
|
+
* (GC reclaims it after the 7-day TTL), and start a fresh child writing
|
|
276
|
+
* to a new file.
|
|
277
|
+
*
|
|
278
|
+
* Only has effect if a capture is currently active. Returns the new file
|
|
279
|
+
* path (or null if no rotation happened or rotation failed).
|
|
280
|
+
*/
|
|
281
|
+
export function rotateCaptureIfNeeded(adbPath) {
|
|
282
|
+
const current = state.filePath;
|
|
283
|
+
if (!current || !isCapturing())
|
|
284
|
+
return null;
|
|
285
|
+
if (!needsRotation(current))
|
|
286
|
+
return null;
|
|
287
|
+
stopCapture();
|
|
288
|
+
try {
|
|
289
|
+
const archived = `${current}.rotated-${Date.now()}`;
|
|
290
|
+
fs.renameSync(current, archived);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Rename failure is non-fatal: the file may have vanished. We still
|
|
294
|
+
// want to start a fresh capture, so fall through to startCapture.
|
|
295
|
+
}
|
|
296
|
+
return startCapture(adbPath);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Filter captured log lines by tag substring (logcat threadtime format puts
|
|
300
|
+
* the tag after the PID/TID columns), optional grep (case-insensitive), and
|
|
301
|
+
* tail to `limit` most-recent matches.
|
|
302
|
+
*
|
|
303
|
+
* Extracted so the pure logic can be tested without a running adb.
|
|
304
|
+
*/
|
|
305
|
+
export function filterLogLines(lines, tag, limit, grep) {
|
|
306
|
+
const tagMatches = lines.filter((line) => line.includes(tag));
|
|
307
|
+
const grepMatches = grep
|
|
308
|
+
? tagMatches.filter((line) => line.toLowerCase().includes(grep.toLowerCase()))
|
|
309
|
+
: tagMatches;
|
|
310
|
+
if (grepMatches.length <= limit)
|
|
311
|
+
return grepMatches;
|
|
312
|
+
return grepMatches.slice(grepMatches.length - limit);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Read the last `maxBytes` of a file without loading the whole thing. Returns
|
|
316
|
+
* the tail as a UTF-8 string, dropping any partial first line so the result
|
|
317
|
+
* starts at a line boundary. Keeps memory bounded regardless of file size,
|
|
318
|
+
* which matters once the capture file grows (and once the web dashboard
|
|
319
|
+
* starts polling for logs in a later PR).
|
|
320
|
+
*
|
|
321
|
+
* Extracted for testability.
|
|
322
|
+
*/
|
|
323
|
+
export function readTailBytes(filePath, maxBytes) {
|
|
324
|
+
let stat;
|
|
325
|
+
try {
|
|
326
|
+
stat = fs.statSync(filePath);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return "";
|
|
330
|
+
}
|
|
331
|
+
if (stat.size === 0)
|
|
332
|
+
return "";
|
|
333
|
+
const readSize = Math.min(stat.size, maxBytes);
|
|
334
|
+
const start = stat.size - readSize;
|
|
335
|
+
const fd = fs.openSync(filePath, "r");
|
|
336
|
+
try {
|
|
337
|
+
const buf = Buffer.alloc(readSize);
|
|
338
|
+
fs.readSync(fd, buf, 0, readSize, start);
|
|
339
|
+
const text = buf.toString("utf-8");
|
|
340
|
+
// If we read from mid-file, drop the partial first line so the caller
|
|
341
|
+
// does not see half-parsed content.
|
|
342
|
+
if (start > 0) {
|
|
343
|
+
const firstNewline = text.indexOf("\n");
|
|
344
|
+
if (firstNewline !== -1)
|
|
345
|
+
return text.slice(firstNewline + 1);
|
|
346
|
+
}
|
|
347
|
+
return text;
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
fs.closeSync(fd);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
// Bounded tail size for tool reads. Large enough to cover most `limit <= 500`
|
|
354
|
+
// queries on chatty traffic; small enough to keep per-read memory predictable.
|
|
355
|
+
// Tuned for the radar-ui polling case (later PR) where many reads per second
|
|
356
|
+
// are plausible.
|
|
357
|
+
const DEFAULT_TAIL_BYTES = 4 * 1024 * 1024;
|
|
358
|
+
/**
|
|
359
|
+
* Read recent captured lines matching tag/grep, tailed to `limit`. Returns
|
|
360
|
+
* null when no capture is active (caller falls back to `adb logcat -d`).
|
|
361
|
+
*
|
|
362
|
+
* Reads only the last DEFAULT_TAIL_BYTES of the capture file so cost stays
|
|
363
|
+
* bounded as the file grows. For typical logcat traffic this window covers
|
|
364
|
+
* several thousand lines, which safely exceeds practical `limit` values.
|
|
365
|
+
*/
|
|
366
|
+
export function readRecentLogs(tag, limit, grep) {
|
|
367
|
+
const filePath = state.filePath;
|
|
368
|
+
if (!filePath)
|
|
369
|
+
return null;
|
|
370
|
+
// Note: rotation is not triggered here. Rotating requires an adb path to
|
|
371
|
+
// start the replacement child, and the read path is intentionally sync +
|
|
372
|
+
// adb-free. Callers that have adb (AndroidTransport.getRecentLogs) can
|
|
373
|
+
// invoke rotateCaptureIfNeeded before reading.
|
|
374
|
+
let tail;
|
|
375
|
+
try {
|
|
376
|
+
tail = readTailBytes(filePath, DEFAULT_TAIL_BYTES);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return null;
|
|
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
|
+
}
|
|
391
|
+
const lines = tail.split("\n").filter(Boolean);
|
|
392
|
+
return filterLogLines(lines, tag, limit, grep);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Install once at process start so the child adb logcat is killed when the
|
|
396
|
+
* MCP server exits. Prevents orphaned adb streams that would hold the
|
|
397
|
+
* device and accumulate across restarts.
|
|
398
|
+
*/
|
|
399
|
+
let cleanupRegistered = false;
|
|
400
|
+
export function registerCleanup() {
|
|
401
|
+
if (cleanupRegistered)
|
|
402
|
+
return;
|
|
403
|
+
cleanupRegistered = true;
|
|
404
|
+
const cleanup = () => stopCapture();
|
|
405
|
+
process.on("exit", cleanup);
|
|
406
|
+
process.on("SIGINT", () => {
|
|
407
|
+
cleanup();
|
|
408
|
+
process.exit(130);
|
|
409
|
+
});
|
|
410
|
+
process.on("SIGTERM", () => {
|
|
411
|
+
cleanup();
|
|
412
|
+
process.exit(143);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
@@ -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
|
|
@@ -62,4 +71,30 @@ export interface DeviceTransport {
|
|
|
62
71
|
* "device not connected" message. Optional; implementations may omit it.
|
|
63
72
|
*/
|
|
64
73
|
getLastForwardError?(): string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Returns the path of the session-scoped log capture file, or null if no
|
|
76
|
+
* capture is running. Surfaced in tool responses so the model can reason
|
|
77
|
+
* about which file's contents it just saw and detect staleness across
|
|
78
|
+
* profile switches or session restarts. Optional; platforms without
|
|
79
|
+
* file-backed capture may omit it.
|
|
80
|
+
*/
|
|
81
|
+
getCaptureFilePath?(): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* List profiles the debug build is running under. Android uses this to
|
|
84
|
+
* expose personal vs work profile; iOS will be a no-op or single entry.
|
|
85
|
+
* Optional; platforms without multi-user models may omit it.
|
|
86
|
+
*/
|
|
87
|
+
listProfiles?(): {
|
|
88
|
+
userId: string;
|
|
89
|
+
label: string;
|
|
90
|
+
}[];
|
|
91
|
+
/**
|
|
92
|
+
* Activate the on-device radar server for a specific user profile.
|
|
93
|
+
* Optional; only implementations that implement listProfiles should
|
|
94
|
+
* implement this.
|
|
95
|
+
*
|
|
96
|
+
* @param userId The user ID from listProfiles
|
|
97
|
+
* @param timeoutMinutes Auto-shutdown timeout in minutes
|
|
98
|
+
*/
|
|
99
|
+
activateForUser?(userId: string, timeoutMinutes: number): void;
|
|
65
100
|
}
|