@gravitylabsllc/porthole 0.1.0 → 0.2.1

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.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  52. package/ui/dist/assets/index-h7VNB9Fl.js +70 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/index.ts ADDED
@@ -0,0 +1,2545 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 Gravity Labs
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { z } from "zod";
7
+ import { DeviceClient, isAttached, isHandshaking, type DeviceEvent } from "./device.js";
8
+ import { TimelineServer } from "./timeline.js";
9
+ import { readFileSync } from "node:fs";
10
+ import { resolveProjectRoot, resolveSdkDir, restartAppAsync, runAdb, runAdbAsync } from "./adb.js";
11
+ import { describe as describeMoment, fromBootMs, momentOf, toBoot } from "./moment.js";
12
+ import {
13
+ CPU_PROBE,
14
+ describeSystem,
15
+ parseCpu,
16
+ parseMemory,
17
+ parseThermal,
18
+ parseTop,
19
+ type SystemContext,
20
+ } from "./system.js";
21
+ import { askTrace, findTraceProcessor, QUESTIONS } from "./perfetto.js";
22
+ import { captureArgs, countPortholeLabels, describeCapture, planCapture } from "./systrace.js";
23
+ import { existsSync, mkdirSync, statSync } from "node:fs";
24
+ import { spawnSync } from "node:child_process";
25
+ import { join, resolve } from "node:path";
26
+ import { pathToFileURL } from "node:url";
27
+ import { buildTrace, describeBudget, num, resolveProfile, str, type Finding } from "./trace.js";
28
+ import {
29
+ UNKNOWN_DEVICE_ID,
30
+ clippedMsOf,
31
+ fillWindowFromDisk,
32
+ sessionsRoot as sessionsRootPath,
33
+ type SessionEvent,
34
+ } from "./sessions.js";
35
+ import { InvalidScenarioError, buildSavedTrace, coverageNote, defaultOutPath, defaultScenarioName, validateScenario, writeSavedTrace } from "./save.js";
36
+ import { Watermark, buildBanner, classificationSummary, classify } from "./watermark.js";
37
+
38
+ /** Read, not retyped: a hardcoded version here drifts from the package. */
39
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
40
+ version: string;
41
+ };
42
+
43
+ const HOST = process.env.PORTHOLE_HOST ?? "127.0.0.1";
44
+ const PORT = Number(process.env.PORTHOLE_PORT ?? 8677);
45
+ const UI_PORT = Number(process.env.PORTHOLE_UI_PORT ?? 8678);
46
+
47
+ /**
48
+ * GRA-89: how much longer than the plan's own recording duration
49
+ * `capture_system_trace` gives the on-device `perfetto` invocation before
50
+ * presuming it wedged — adb's own connect/attach overhead plus whatever
51
+ * margin covers a slow device, on top of the `-t Ns` the command itself
52
+ * asked to run for.
53
+ */
54
+ const CAPTURE_ADB_TIMEOUT_BUFFER_MS = 30_000;
55
+
56
+ /**
57
+ * GRA-186: how long `capture_system_trace`'s `restartApp: true` path polls
58
+ * for the on-device trace file before giving up and restarting anyway.
59
+ *
60
+ * The recording itself is one `runAdbAsync` call that only resolves when the
61
+ * whole `-t Ns` window is over (GRA-89), so it gives no mid-flight signal
62
+ * that the session has actually started — restarting the app before it has
63
+ * would just repeat the case this option exists to fix, restarting after the
64
+ * whole window is over would restart to no purpose at all. Perfetto creates
65
+ * its output file on-device as soon as the session starts, before it writes
66
+ * a single event into it, so polling for that file's existence is the
67
+ * smallest reliable "it has started" signal available without parsing
68
+ * perfetto's own stderr. If it never appears within this bound the restart
69
+ * still goes ahead — the whole point is to make the app tag visible to the
70
+ * target process, and doing that late is far better than not doing it.
71
+ */
72
+ const RESTART_POLL_TIMEOUT_MS = 5_000;
73
+ const RESTART_POLL_INTERVAL_MS = 150;
74
+
75
+ interface AdbCallOptions {
76
+ serial?: string;
77
+ env?: NodeJS.ProcessEnv;
78
+ binary?: string;
79
+ }
80
+
81
+ async function waitForCaptureToStart(devicePath: string, options: AdbCallOptions): Promise<void> {
82
+ const deadline = Date.now() + RESTART_POLL_TIMEOUT_MS;
83
+ for (;;) {
84
+ const probe = await runAdbAsync(["shell", "test", "-e", devicePath], { ...options, timeoutMs: 2_000 });
85
+ if (probe.ok) return;
86
+ if (Date.now() >= deadline) return;
87
+ await new Promise((resolveWait) => setTimeout(resolveWait, RESTART_POLL_INTERVAL_MS));
88
+ }
89
+ }
90
+
91
+ export interface PortholeServerOptions {
92
+ /** Injected in tests; a real one is created in `createPortholeServer` otherwise. */
93
+ device?: DeviceClient;
94
+ timeline?: TimelineServer;
95
+ version?: string;
96
+ /**
97
+ * Test-only. Passed straight through to every `runAdbAsync` call
98
+ * `capture_system_trace` makes; every real caller leaves it undefined, in
99
+ * which case each spawned adb inherits `process.env` exactly as it always
100
+ * did. This exists so a test can point `findAdb()` at a stand-in adb that
101
+ * needs its own `NODE_OPTIONS` (or any other variable) without mutating
102
+ * the real `process.env` — a global shared with every other test running
103
+ * in the same worker. See `runAdbAsync`'s own `env` option in `adb.ts` for
104
+ * the incident that made this worth a parameter rather than a shared
105
+ * global.
106
+ */
107
+ adbEnv?: NodeJS.ProcessEnv;
108
+ /**
109
+ * Test-only, same reasoning as `adbEnv`: overrides `findAdb()`'s own
110
+ * resolution for every `runAdbAsync` call `capture_system_trace` makes, so
111
+ * a test can point it at a real, controllable process without setting
112
+ * `PORTHOLE_SDK_DIR`/`ANDROID_HOME` on the real `process.env` — again a
113
+ * global, and again shared with `adb.test.ts`'s own tests of that exact
114
+ * resolution, running concurrently in the same worker.
115
+ */
116
+ adbBinary?: string;
117
+ }
118
+
119
+ export interface PortholeServer {
120
+ server: McpServer;
121
+ device: DeviceClient;
122
+ timeline: TimelineServer;
123
+ }
124
+
125
+ /**
126
+ * Builds the MCP server and registers every tool against `device` and
127
+ * `timeline`, but does not start either and does not connect any transport.
128
+ *
129
+ * Split out from the boot sequence at the bottom of this file so a test can
130
+ * hand it a fake device speaking the real wire protocol (see
131
+ * `testing/harness.ts`) and a fresh `TimelineServer`, register the real
132
+ * tools, and call them the way an agent would — through an MCP client over an
133
+ * in-memory transport — rather than grepping this file's source for the
134
+ * properties a tool is supposed to have.
135
+ */
136
+ /** A tool's `content` array is one text block (summary only, `fail()`'s
137
+ * shape) or two (summary then payload, `ok()`'s shape). Both blocks are
138
+ * plain text on the wire; only the count tells a consumer which is which.
139
+ */
140
+ type ToolContentBlock = { type: "text"; text: string };
141
+
142
+ /**
143
+ * GRA-171: builds the `content` array both `ok()` and `fail()` return, so
144
+ * there is exactly one place a summary and a payload are put together.
145
+ *
146
+ * GRA-169 defended this same seam by *normalising*: `ok()` joined summary
147
+ * and payload with the literal string `"\n\n"` and collapsed every blank
148
+ * line out of the summary first, because a consumer found the payload by
149
+ * searching the *text* for that substring — a property of the string, not
150
+ * of anything the protocol enforced. Interpolate a device value containing
151
+ * a blank line (`hello.device`, say) into the summary and the search finds
152
+ * the wrong occurrence, slicing from the middle of the prose instead of the
153
+ * start of the JSON; `JSON.parse` throws and the payload is gone, not
154
+ * merely mislabelled. Normalising away every blank line closed that one
155
+ * failure mode (14 tests, no known gap) but it was still a textual fix for
156
+ * a textual bug: it bought safety by silently rewriting an author's
157
+ * intended blank line into a space, and it left `fail()` unguarded, since
158
+ * `fail()` never called `collapseBlankLines()` — a second chokepoint nobody
159
+ * had reason to notice was missing.
160
+ *
161
+ * GRA-171's structural option was on the table from the start but rejected
162
+ * for a reason that did not hold: 0.1.0 wire compatibility. The founder's
163
+ * 2026-09-15 decision that 0.2.0 may change this delimiter removes that
164
+ * obstacle, so the summary and the payload are now two separate blocks in
165
+ * the `content` array, never one string joined by a marker. **This is what
166
+ * "the delimiter cannot occur in data" means concretely: there is no
167
+ * delimiter for a value to collide with, at any position, in either
168
+ * block.** A consumer takes `content[0]` for the summary and `content[1]`
169
+ * for the payload by position — the same way this function decides which
170
+ * is which by argument count, not by scanning either string for anything.
171
+ * No text search happens over either block's contents at all, so a value
172
+ * containing every shape GRA-169 catalogued (CRLF pairs, three to seven
173
+ * consecutive newlines, whitespace-only lines, U+2028/U+2029, NBSP, form
174
+ * feed, leading and trailing blank lines, the empty string, or literally
175
+ * `"\n\n"` itself) is carried verbatim in its own block and never
176
+ * inspected here. If a future call site ever concatenated the payload back
177
+ * into the summary string by hand instead of passing it as the `payload`
178
+ * argument, that call site alone would reintroduce a textual seam — this
179
+ * function cannot protect against bypassing itself, only against being
180
+ * used and still failing.
181
+ *
182
+ * `collapseBlankLines()` (GRA-169) is deleted rather than kept alongside
183
+ * this, and that is a deliberate call, not reflex. GRA-168's CRLF
184
+ * normalisation was kept after GRA-166's scanner made it redundant for
185
+ * *correctness*, because it still served a second purpose — canonical
186
+ * output — that removing it would have destroyed. `collapseBlankLines()`
187
+ * has no second purpose: its only job, on every call site, was defending
188
+ * the "\n\n" delimiter, and it always cost prose fidelity to do it — a
189
+ * device value an agent might want verbatim came back with its blank lines
190
+ * flattened to spaces. With no delimiter left to defend, keeping it would
191
+ * mean paying that cost for a defence nothing needs any more. That is a
192
+ * reason to remove it, not merely permission to.
193
+ *
194
+ * Module-level and exported, as `collapseBlankLines()` was, so
195
+ * `index.test.ts` can call this directly with adversarial summaries and
196
+ * payloads and assert on the `content` array's shape and the payload
197
+ * block's JSON round-trip — no `vi.mock`/`spyOn` needed, because there is
198
+ * no module-local binding to intercept: the function under test *is* the
199
+ * chokepoint, not a wrapper around one.
200
+ */
201
+ export function joinSummaryAndPayload(summary: string, ...payload: [unknown] | []): ToolContentBlock[] {
202
+ const blocks: ToolContentBlock[] = [{ type: "text", text: summary }];
203
+ if (payload.length > 0) {
204
+ // JSON.stringify(undefined) is the JS value `undefined`, not a string —
205
+ // the `?? "null"` keeps this block's `text` a real string always (the
206
+ // MCP content schema requires one), and keeps the payload something
207
+ // `JSON.parse` can read back rather than a block with no usable text.
208
+ blocks.push({ type: "text", text: JSON.stringify(payload[0], null, 2) ?? "null" });
209
+ }
210
+ return blocks;
211
+ }
212
+
213
+ export function createPortholeServer(options: PortholeServerOptions = {}): PortholeServer {
214
+ // GRA-53 `#window-fallback`: the real boot path gets on-disk session
215
+ // persistence; a test that injects its own `options.device` (the harness
216
+ // in `testing/harness.ts`, or a hand-built fake) is unaffected — this
217
+ // branch only runs when nothing was injected.
218
+ const device = options.device ?? new DeviceClient(HOST, PORT, sessionsRootPath(resolveProjectRoot().directory));
219
+ const timeline = options.timeline ?? new TimelineServer(device, UI_PORT);
220
+ const adbEnv = options.adbEnv;
221
+ const adbBinary = options.adbBinary;
222
+ // GRA-55: one watermark per process (see watermark.ts's module doc comment
223
+ // for why not per connection), re-opened against whichever session
224
+ // directory is current every time a tool runs — cheap, since `open()` is a
225
+ // no-op once the directory has not changed.
226
+ const watermark = new Watermark();
227
+ /** Set by `resolveWindowSince` on a first-ever `since: "last"` call, consumed by `ok()` (AC5). */
228
+ let pendingFirstEverNote: string | null = null;
229
+ const FIRST_EVER_NOTE =
230
+ 'First call this session: "since": "last" has nothing to start from yet, so this is the ' +
231
+ "whole buffer, the same default as before since existed. ";
232
+
233
+ const server = new McpServer({
234
+ name: "porthole",
235
+ version: options.version ?? pkg.version,
236
+ });
237
+
238
+ type ToolResult = { content: ToolContentBlock[]; isError?: boolean };
239
+
240
+ /**
241
+ * Summary first, payload second — as two `content` blocks now (see
242
+ * `joinSummaryAndPayload()` above), not one string joined by a delimiter.
243
+ * GRA-171: the post-collapse assertion this function used to carry
244
+ * (`ok(): summary still contains a blank line after
245
+ * collapseBlankLines()`) is deleted along with `collapseBlankLines()`
246
+ * itself, and deliberately, not by omission. That assertion guarded one
247
+ * specific failure of the textual design — the delimiter search finding
248
+ * the wrong "\n\n" — and GRA-169's own QA had already shown it was
249
+ * unreachable by any test without a production refactor, because `ok()`
250
+ * called `collapseBlankLines` by a module-local binding no mock could
251
+ * intercept. A structural join has no blank-line assumption to violate:
252
+ * there is no search, so there is nothing for the assertion to catch that
253
+ * `joinSummaryAndPayload()`'s own direct tests do not already cover by
254
+ * construction. Keeping an assertion for a failure mode that no longer
255
+ * exists would be exactly the untested-claim shape this ticket exists to
256
+ * end, just moved from "untested" to "vacuous".
257
+ */
258
+ /**
259
+ * GRA-55: the one place every successful tool result passes through, which
260
+ * is what "the banner goes on every tool, built in one place" (the EM's
261
+ * own condition for this ticket) means concretely — no per-tool call adds
262
+ * it, so no tool can forget it the way the ticket's own history names as
263
+ * the risk ("the one tool that forgets is the one the agent was using
264
+ * when the ANR happened"). `attachSinceLastAndBanner()` does the actual
265
+ * work; this function stays a thin async wrapper so every existing call
266
+ * site (`return ok(summary, payload)`, inside an already-`async` handler)
267
+ * keeps compiling unchanged — an `async` function returning a `Promise`
268
+ * is flattened by the caller's own `await`/`return` exactly as returning
269
+ * the value directly would be.
270
+ */
271
+ async function ok(summary: string, payload: unknown): Promise<ToolResult> {
272
+ const { summary: withBanner, payload: withSinceLast } = await attachSinceLastAndBanner(summary, payload);
273
+ return { content: joinSummaryAndPayload(withBanner, withSinceLast) };
274
+ }
275
+
276
+ /**
277
+ * GRA-171: routes through `joinSummaryAndPayload()` too, with no payload
278
+ * block (a bare `payload.length > 0` check away from getting one, if an
279
+ * error ever needs structured detail). Before this ticket `fail()` built
280
+ * its `content` array by hand and never passed through the chokepoint
281
+ * `ok()` used for `collapseBlankLines()` — the GRA-169 ticket named this
282
+ * explicitly as half of why normalisation could not be a complete fix.
283
+ * With both going through the same function there is exactly one place
284
+ * a `content` array is assembled, for success or failure alike.
285
+ */
286
+ function fail(error: unknown): ToolResult {
287
+ const message = error instanceof Error ? error.message : String(error);
288
+ return { content: joinSummaryAndPayload(message), isError: true };
289
+ }
290
+
291
+ async function call<T>(
292
+ method: string,
293
+ params: Record<string, unknown>,
294
+ summarise: (value: T) => string,
295
+ ) {
296
+ try {
297
+ const result = await device.request<T>(method, params);
298
+ return ok(summarise(result), result);
299
+ } catch (error) {
300
+ return fail(error);
301
+ }
302
+ }
303
+
304
+ type ExitedProcess = { packageName: string; device: string; disconnectedAt: string };
305
+
306
+ /**
307
+ * GRA-163: the payload half of the stale-ring fix, so an agent can tell
308
+ * live data from post-mortem data without parsing the prose next to it.
309
+ *
310
+ * GRA-163 QA round 1: this used to take the caller's own `connected`
311
+ * value as a parameter, so it agreed with whichever sense of "connected"
312
+ * the caller happened to be using for its own boolean field — loose
313
+ * (isAttached) on an empty-ring branch, strict (pending === null) on a
314
+ * non-empty one. That is fine for the `connected` field itself (GRA-157
315
+ * chose the loose sense on purpose, for "should an agent keep polling"),
316
+ * but `exitedProcess` answers a different question — "is what I am about
317
+ * to hand back confirmed to belong to the running process" — and that
318
+ * question has exactly one right answer regardless of which `connected`
319
+ * a given branch reports, or it is the same defect this ticket exists to
320
+ * remove: three tools (or two branches of one tool) disagreeing about the
321
+ * same state. So this is gated on `device.pendingMessage() === null`
322
+ * directly — the strict sense, always, independent of the caller's own
323
+ * `connected` — which is also why `porthole_status` (which never had a
324
+ * `connected` field at all) and `findings`/`what_was_happening`'s
325
+ * empty-ring branches (which report the loose sense) now all agree with
326
+ * the non-empty branches on when this is null.
327
+ *
328
+ * Null whenever a session is currently confirmed live, or nothing has
329
+ * ever exited in this server's lifetime (`device.lastExited` starts
330
+ * null). Otherwise the last confirmed process and when its socket closed,
331
+ * in the same shape every tool that calls this returns it in, so the
332
+ * three tools' payloads agree on more than just prose.
333
+ */
334
+ function exitedProcessField(): ExitedProcess | null {
335
+ if (device.pendingMessage() === null || !device.lastExited) return null;
336
+ const { hello, disconnectedAt } = device.lastExited;
337
+ return {
338
+ packageName: hello.packageName,
339
+ device: hello.device,
340
+ disconnectedAt: new Date(disconnectedAt).toISOString(),
341
+ };
342
+ }
343
+
344
+ /**
345
+ * The prose half, and — after QA round 1 — the *only* place any tool says
346
+ * anything about an exited process. `device.ts`'s `pendingMessage()` used
347
+ * to append its own sentence here ("whatever is still buffered is from
348
+ * X"), which was wrong on any branch where nothing actually is buffered:
349
+ * `pendingMessage()` has no visibility into `timeline.buffer()`, only
350
+ * this file does. `hasBufferedData` must be true only when the ring this
351
+ * particular answer is about genuinely has content — every call site
352
+ * below passes the same fact it already used to choose its branch (the
353
+ * `!span`/`events.length === 0` checks), never a guess.
354
+ *
355
+ * `connected` (QA round 2): `exited` is null in two different
356
+ * situations — a confirmed live session (`connected: true`, nothing to
357
+ * say), and no confirmed live session *and* nothing has ever exited in
358
+ * this server's lifetime (the very first connection, still handshaking,
359
+ * with a ring already non-empty — GRA-163's own race mechanism:
360
+ * `buildRaceRig()`, push one event before the deferred `hello` resolves).
361
+ * The second case still has real data with no confirmed owner and needs
362
+ * its own sentence — the original fix said so ("Nothing has confirmed
363
+ * itself as the running process yet...") until this function's QA round 1
364
+ * rewrite silently dropped it while consolidating three call sites into
365
+ * one. Restored here, gated on there being data to caveat in the first
366
+ * place: an empty ring with no known predecessor has nothing worth
367
+ * flagging beyond what `pending`'s own message already says.
368
+ */
369
+ function exitedProcessNotice(
370
+ exited: ExitedProcess | null,
371
+ hasBufferedData: boolean,
372
+ connected: boolean,
373
+ ): string {
374
+ if (exited) {
375
+ return hasBufferedData
376
+ ? `${exited.packageName} on ${exited.device} exited at ${exited.disconnectedAt}; what ` +
377
+ "follows is from it, not from what is running now. "
378
+ : `${exited.packageName} on ${exited.device} exited at ${exited.disconnectedAt}; nothing ` +
379
+ "is currently buffered from it. ";
380
+ }
381
+ if (!connected && hasBufferedData) {
382
+ return (
383
+ "Nothing has confirmed itself as the running process yet, so what follows is not yet " +
384
+ "confirmed to be live. "
385
+ );
386
+ }
387
+ return "";
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // GRA-58: why the app died, folded into porthole_status rather than a new tool
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /** How many recent exits `porthole_status`'s `exits.recent` carries — a literal, not "however many fit". */
395
+ const EXITS_SECTION_CAP = 10;
396
+
397
+ /** How long after an exit it is still worth calling out as *why* the app is not connected right now. */
398
+ const RECENT_EXIT_MS = 5 * 60 * 1000;
399
+
400
+ interface ExitSummary {
401
+ reason: string;
402
+ /**
403
+ * GRA-188: epoch milliseconds — `ApplicationExitInfo.getTimestamp()`'s
404
+ * own unit, not the device-uptime clock every other timestamp in this
405
+ * surface uses — and exactly the value `exitTrace` below accepts:
406
+ * quoting this field back works with no conversion, which used to
407
+ * require a `Date.parse` an agent had to think to reach for on its own.
408
+ */
409
+ timestamp: number;
410
+ /** The same instant as `timestamp`, spelled ISO-8601 for a human or a log line. `exitTrace` accepts this too (GRA-188) since it is the form most likely to get copied first. */
411
+ at: string;
412
+ versionName: string | null;
413
+ versionAssumed: boolean;
414
+ topAppFrame: string | null;
415
+ }
416
+
417
+ /**
418
+ * `porthole_status`'s `exits` payload: the most recent deaths this
419
+ * process's ring still holds (the runtime's `ExitInfoCollector` put them
420
+ * there at install time — see `Protocol.kt#exit`), newest first, and the
421
+ * one fact that needs no ring content at all: whether the exit-reason API
422
+ * exists on this device. `hello.sdkInt` already answers that — the
423
+ * runtime emits nothing at all below API 30 (GRA-58's own ruling), so
424
+ * there is no event to read for it either way.
425
+ */
426
+ function exitsSection(): { apiUnavailable: string | null; recent: ExitSummary[] } {
427
+ const hello = device.hello ?? device.lastExited?.hello ?? null;
428
+ const sdkInt = hello?.sdkInt;
429
+ const apiUnavailable =
430
+ sdkInt !== undefined && sdkInt < 30
431
+ ? `The exit-reason API needs Android 11 (API 30); this device reports API ${sdkInt}, so no exit history is available.`
432
+ : null;
433
+
434
+ const recent = timeline
435
+ .buffer()
436
+ .filter((e) => e.event === "exit")
437
+ .slice()
438
+ .sort((a, b) => num(b.data.timestamp) - num(a.data.timestamp))
439
+ .slice(0, EXITS_SECTION_CAP)
440
+ .map(
441
+ (e): ExitSummary => ({
442
+ reason: str(e.data.reason),
443
+ timestamp: num(e.data.timestamp),
444
+ at: new Date(num(e.data.timestamp)).toISOString(),
445
+ versionName: e.data.versionName != null ? str(e.data.versionName) : null,
446
+ versionAssumed: e.data.versionAssumed === true,
447
+ topAppFrame: str(e.data.mainStack).split("\n")[0] || null,
448
+ }),
449
+ );
450
+
451
+ return { apiUnavailable, recent };
452
+ }
453
+
454
+ /**
455
+ * "the app is not connected because it died and why" (GRA-58's own
456
+ * wording): only when there is genuinely no live session right now, and
457
+ * only when the most recent exit is recent enough that it is plausibly
458
+ * *why* — an exit from an hour ago says nothing about a disconnect that
459
+ * just happened.
460
+ */
461
+ function exitDeathNotice(recent: ExitSummary[], stronglyConnected: boolean): string {
462
+ if (stronglyConnected || recent.length === 0) return "";
463
+ const latest = recent[0];
464
+ const ageMs = Date.now() - latest.timestamp;
465
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > RECENT_EXIT_MS) return "";
466
+ const build = latest.versionName
467
+ ? `${latest.versionName}${latest.versionAssumed ? " (assumed)" : ""}`
468
+ : "an unknown build";
469
+ const frame = latest.topAppFrame ? ` Top app frame: ${latest.topAppFrame}.` : "";
470
+ return (
471
+ `Not connected because the app died: ${latest.reason} (${build}) at ${latest.at}.` + frame + " "
472
+ );
473
+ }
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // windows
477
+ // ---------------------------------------------------------------------------
478
+
479
+ /**
480
+ * The same three parameters on every tool that looks at a span of time.
481
+ *
482
+ * They used to differ per tool — `timeline` took only `sinceMs`, which made it
483
+ * the one tool that could not be asked about a moment the others had just
484
+ * named. An agent that cannot carry a window between calls compares two
485
+ * different windows and does not notice.
486
+ *
487
+ * GRA-120: `sinceMs` anchors to `to` (explicit, or this host's own estimate
488
+ * of "now" below) rather than to a true "now" the way the runtime's own
489
+ * `Window.resolve` does — this host has no device clock, only the
490
+ * timestamps events arrive stamped with, so "now" here can only ever be
491
+ * "the newest thing we have seen", never the actual current instant. See
492
+ * `resolveWindow` below for exactly what that means and why it is the
493
+ * honest choice rather than an alignment gap.
494
+ */
495
+ const windowShape = {
496
+ sinceMs: z
497
+ .number()
498
+ .int()
499
+ .positive()
500
+ .optional()
501
+ .describe(
502
+ "Look back this many milliseconds from `to`. If `to` is omitted, from this host's best " +
503
+ "estimate of the device's current time (see `to`'s description), or from the device's " +
504
+ "own current time when nothing is buffered here yet. Ignored if `from` is given.",
505
+ ),
506
+ from: z
507
+ .number()
508
+ .int()
509
+ .optional()
510
+ .describe(
511
+ "Absolute start on the device uptime clock that every event carries. Quote the `window` " +
512
+ "from an earlier result to ask a second question about the same span.",
513
+ ),
514
+ to: z
515
+ .number()
516
+ .int()
517
+ .optional()
518
+ .describe(
519
+ "Absolute end, same clock. Defaults to this host's best estimate of the device's current " +
520
+ "time: the newest event currently buffered, since the host has no clock of the device's " +
521
+ "own to read.",
522
+ ),
523
+ // GRA-55: only consulted when none of sinceMs/from/to above are given —
524
+ // same precedence sinceMs already has against from/to. "last" is the
525
+ // default rather than a value someone has to ask for, because the whole
526
+ // point is that an agent should not have to guess a lookback.
527
+ since: z
528
+ .enum(["last", "all"])
529
+ .optional()
530
+ .describe(
531
+ '"last" (the default when sinceMs/from/to are all omitted) starts where the previous ' +
532
+ "window-taking tool call on this session left off, so nothing since is missed and " +
533
+ 'nothing already examined is re-read. On the very first call this session has ever made, ' +
534
+ '"last" behaves exactly like today\'s default (the whole buffer). "all" is the reset: the ' +
535
+ "whole buffer plus disk, same as every call before this existed, and it clears the " +
536
+ "watermark that \"last\" tracks.",
537
+ ),
538
+ };
539
+
540
+ interface Window {
541
+ sinceMs?: number;
542
+ from?: number;
543
+ to?: number;
544
+ since?: "last" | "all";
545
+ }
546
+
547
+ /**
548
+ * The span actually examined, resolved against the buffer so it can be
549
+ * quoted back.
550
+ *
551
+ * GRA-120: brought into line with the runtime's `Window.resolve` on the
552
+ * two points that were outright bugs — a negative `from` used to reach
553
+ * back before the device existed instead of clamping at 0 (`Window.kt`'s
554
+ * own words: "not `Long.MIN_VALUE`: a timestamp on this clock cannot be
555
+ * negative"), and an inverted window (`from` after `to`, or a `to` in the
556
+ * past that `sinceMs` does not reach) used to hand a tool a backwards span
557
+ * instead of being refused the way the tool already refuses "no window at
558
+ * all". Both are fixed here, once, rather than in every caller.
559
+ *
560
+ * `sinceMs` anchoring to `to` (explicit or defaulted) rather than to a
561
+ * true "now" is deliberately *not* changed to match the runtime bit for
562
+ * bit: the runtime reads its own clock, and this host cannot — its only
563
+ * source for "now" is the timestamp on the newest event it has seen,
564
+ * which is what `to` already defaults to below. So "anchor `sinceMs` to
565
+ * `to`" and "anchor it to the device's actual now" are the same rule
566
+ * here, applied with the one clock this process actually has access to.
567
+ * In the normal case this never surfaces as a disagreement anyway: every
568
+ * caller that resolves a window here (`resolveWindowSince`) sends the
569
+ * device *only* the resolved `from`/`to`, never a raw `sinceMs` — the
570
+ * device's own `sinceMs` handling in `Window.resolve` is exercised by a
571
+ * request that reaches it directly, not by anything this function
572
+ * produces.
573
+ */
574
+ function resolveWindow(w: Window): { from: number; to: number; ms: number } | null {
575
+ const events = timeline.buffer();
576
+ if (events.length === 0) {
577
+ // GRA-53: an empty *live* buffer used to mean "no window at all" —
578
+ // right when the only source was memory. It no longer is: an agent
579
+ // quoting a `window` from an earlier answer (the pattern every tool
580
+ // description here recommends) is asking about a moment on the device
581
+ // uptime clock, which is exactly as answerable from disk after the MCP
582
+ // server restarts as it was from the ring before. Only the explicit
583
+ // case is widened — `sinceMs`-relative-to-"now" still has no "now"
584
+ // without a live buffer to take it from, so that shape still returns
585
+ // null exactly as before.
586
+ if (w.from !== undefined && w.to !== undefined) {
587
+ const from = Math.max(0, w.from);
588
+ const to = w.to;
589
+ // Refused the same way the tool already refuses a bad window:
590
+ // `resolveWindow` returning null, which every caller already treats
591
+ // as "nothing to answer from" rather than a distinct error path.
592
+ if (from > to) return null;
593
+ return { from, to, ms: Math.max(0, to - from) };
594
+ }
595
+ // GRA-120 QA round 1: `sinceMs` with an explicit `to` needs no "now"
596
+ // at all — the rule is `(to - sinceMs)..to` on both halves — so it is
597
+ // resolved here even on a cold buffer. Forwarding it raw would let the
598
+ // device anchor the lookback to its own clock instead, which is the
599
+ // disagreement this ticket exists to remove. Only `sinceMs` alone (no
600
+ // `to`) still returns null: that shape genuinely needs a "now", and the
601
+ // device's is the right one when this host has nothing buffered.
602
+ if (w.sinceMs !== undefined && w.to !== undefined) {
603
+ const to = w.to;
604
+ const from = Math.max(0, to - w.sinceMs);
605
+ if (from > to) return null;
606
+ return { from, to, ms: Math.max(0, to - from) };
607
+ }
608
+ return null;
609
+ }
610
+ const newest = events[events.length - 1].t;
611
+ const oldest = events[0].t;
612
+ const to = w.to ?? newest;
613
+ const from = Math.max(0, w.from ?? (w.sinceMs !== undefined ? to - w.sinceMs : oldest));
614
+ if (from > to) return null;
615
+ return { from, to, ms: Math.max(0, to - from) };
616
+ }
617
+
618
+ /**
619
+ * The identity `fillWindowFromDisk` should look up sessions under: the
620
+ * currently-connected process's `hello`, or — the post-mortem case this
621
+ * whole ticket is about — the last one `device.ts` saw exit. Null only
622
+ * when neither has ever existed (never connected, this process's whole
623
+ * life).
624
+ */
625
+ function currentIdentity(): { packageName: string; deviceId: string } | null {
626
+ const hello = device.hello ?? device.lastExited?.hello ?? null;
627
+ if (!hello) return null;
628
+ return {
629
+ packageName: hello.packageName,
630
+ deviceId: (hello as { deviceId?: string }).deviceId ?? UNKNOWN_DEVICE_ID,
631
+ };
632
+ }
633
+
634
+ /**
635
+ * `findings`/`what_was_happening`/`timeline`'s one shared call into
636
+ * `sessions.ts` — see that module's `fillWindowFromDisk` doc comment for
637
+ * why routing all three through the same function is the point, not an
638
+ * incidental convenience (GRA-163's history is full of what happens when
639
+ * three tools each hand-roll the same merge).
640
+ */
641
+ async function mergeWithDisk(from: number, to: number) {
642
+ return fillWindowFromDisk({
643
+ root: device.sessions?.root ?? sessionsRootPath(resolveProjectRoot().directory),
644
+ identity: currentIdentity(),
645
+ buffered: timeline.buffer() as unknown as SessionEvent[],
646
+ currentSessionDir: device.sessions?.currentDir() ?? null,
647
+ from,
648
+ to,
649
+ });
650
+ }
651
+
652
+ /** The session directory `watermark` should currently be reading/writing — the same one `mergeWithDisk` already uses, so the two never disagree about which session is "current". */
653
+ function currentWatermarkDir(): string | null {
654
+ return device.sessions?.currentDir() ?? null;
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // GRA-55: since: "last", and the banner every tool result carries
659
+ // ---------------------------------------------------------------------------
660
+
661
+ /** What a resolved window carries once `since` has been folded in — `resolveWindow`'s own result plus whether this call was `since: "last"`-shaped, which `findings`' classification needs. */
662
+ interface ResolvedWindow {
663
+ from: number;
664
+ to: number;
665
+ ms: number;
666
+ /** True for both "this call explicitly asked for since: last" and "no window was given at all", since the latter defaults to the former. False for an explicit sinceMs/from/to, and for since: "all". */
667
+ sinceLast: boolean;
668
+ /** AC5: true only for a since:"last" call with no watermark yet — "behaves exactly as today's default and says so in the summary line" is this flag reaching the caller. */
669
+ firstEver: boolean;
670
+ /**
671
+ * GRA-189: true only for the "nothing new, and nothing to fall back to"
672
+ * shape at the very end of `resolveWindowSince` below — a genuinely
673
+ * empty `since: "last"` window with no previous `findings` digest to
674
+ * re-ask. Deliberately distinct from an ordinary zero-width *digest*
675
+ * re-ask (GRA-55 AC1's "two calls back to back with nothing between
676
+ * them," which can also land on `from === to`): that shape still has a
677
+ * real previous window to reclassify against and must keep doing so, so
678
+ * only this one carries the flag. `findings` uses it to skip running the
679
+ * analysis at all and say "nothing new" instead of "0s examined."
680
+ */
681
+ nothingNew: boolean;
682
+ }
683
+
684
+ /**
685
+ * Folds `since` into `resolveWindow`, and records `lastExaminedT` on every
686
+ * resolution — the one chokepoint every window-taking tool (`findings`,
687
+ * `save_moment`, `recompositions`, `frames`, `blocking`, `logs`,
688
+ * `timeline`) calls instead of `resolveWindow` directly, so "since: last
689
+ * updates the watermark" cannot be true for six tools and forgotten by a
690
+ * seventh.
691
+ *
692
+ * Explicit `sinceMs`/`from`/`to` wins outright, exactly as it always has —
693
+ * `since` only ever supplies a default for when none of those were given.
694
+ *
695
+ * `since: "last"` with nothing yet in the watermark behaves exactly like
696
+ * today's default (AC5): the whole buffer, same as `resolveWindow({})`.
697
+ * With a watermark and new events since it, the window is `[lastExaminedT,
698
+ * newest]` — genuinely new material only. With a watermark and *nothing*
699
+ * new (the AC1 case: two calls back to back with no interaction between
700
+ * them), there is no new material to narrow to, and narrowing there
701
+ * anyway would return an empty window whose zero findings would then look
702
+ * exactly like "everything resolved" — the opposite of AC1's "the second
703
+ * call marks everything ongoing." So this falls back to re-asking the
704
+ * exact window the last `findings` call covered (its digest, if there is
705
+ * one): the same question again, honestly, which is what "nothing
706
+ * happened" actually means here.
707
+ */
708
+ async function resolveWindowSince(w: Window): Promise<ResolvedWindow | null> {
709
+ await watermark.open(currentWatermarkDir());
710
+
711
+ if (w.sinceMs !== undefined || w.from !== undefined || w.to !== undefined) {
712
+ const span = resolveWindow(w);
713
+ if (span) await watermark.recordExamined(span.to);
714
+ return span ? { ...span, sinceLast: false, firstEver: false, nothingNew: false } : null;
715
+ }
716
+
717
+ const since = w.since ?? "last";
718
+ if (since === "all") {
719
+ await watermark.reset();
720
+ const span = resolveWindow({});
721
+ if (span) await watermark.recordExamined(span.to);
722
+ return span ? { ...span, sinceLast: false, firstEver: false, nothingNew: false } : null;
723
+ }
724
+
725
+ const state = watermark.get();
726
+ if (state.lastExaminedT === null) {
727
+ // AC5: no watermark yet — the whole buffer, exactly as if `since` did
728
+ // not exist. `firstEver: true` is what lets the caller say so.
729
+ const span = resolveWindow({});
730
+ if (span) await watermark.recordExamined(span.to);
731
+ // AC5's "says so": noted here, once, and consumed by `ok()` for
732
+ // whichever tool made this call — not narrated per tool, so the six
733
+ // other window-taking tools cannot each forget it.
734
+ if (span) pendingFirstEverNote = FIRST_EVER_NOTE;
735
+ return span ? { ...span, sinceLast: true, firstEver: true, nothingNew: false } : null;
736
+ }
737
+
738
+ const buffered = timeline.buffer();
739
+ const liveNewest = buffered.length > 0 ? buffered[buffered.length - 1].t : null;
740
+ if (liveNewest !== null && liveNewest > state.lastExaminedT) {
741
+ // Exclusive lower bound: `lastExaminedT` was the `to` of whatever
742
+ // window was examined last, and every window here (like
743
+ // `resolveWindow`'s own) treats its bounds as inclusive. Starting the
744
+ // next one at the same value would re-examine that one boundary event
745
+ // twice across two consecutive since:"last" windows — harmless for a
746
+ // raw event count, but exactly the kind of double-count that would
747
+ // make a finding resting on that single event look "still happening"
748
+ // one call after it actually stopped.
749
+ const from = state.lastExaminedT + 1;
750
+ const span = { from, to: liveNewest, ms: Math.max(0, liveNewest - from) };
751
+ await watermark.recordExamined(span.to);
752
+ return { ...span, sinceLast: true, firstEver: false, nothingNew: false };
753
+ }
754
+
755
+ if (state.digest) {
756
+ const { from, to } = state.digest.window;
757
+ return { from, to, ms: Math.max(0, to - from), sinceLast: true, firstEver: false, nothingNew: false };
758
+ }
759
+
760
+ // GRA-189: nothing new, and nothing to fall back to (a window-taking
761
+ // tool other than `findings` was the only thing ever called before this
762
+ // — or, the device case, before an earlier MCP process this one's
763
+ // watermark.json survived). An honest zero-width window rather than a
764
+ // guess, and `nothingNew: true` so the caller skips analysing it and
765
+ // says so plainly instead of reporting "0s examined."
766
+ return {
767
+ from: state.lastExaminedT,
768
+ to: state.lastExaminedT,
769
+ ms: 0,
770
+ sinceLast: true,
771
+ firstEver: false,
772
+ nothingNew: true,
773
+ };
774
+ }
775
+
776
+ /** The structured half of the banner (ruling 5's "an agent that has to regex a sentence to know whether to act is a worse agent"). */
777
+ interface SinceLast {
778
+ errors: number;
779
+ firstAt: number;
780
+ lastAt: number;
781
+ }
782
+
783
+ /**
784
+ * The banner's actual construction: every error-severity finding produced
785
+ * by the events between `lastReportedErrorT` (exclusive, so nothing is
786
+ * ever shown twice) and the newest event this process currently knows
787
+ * about. Advances `lastReportedErrorT` whenever it looks — including when
788
+ * it finds nothing to report — so a quiet stretch does not get re-scanned
789
+ * from the same old boundary on every subsequent call.
790
+ */
791
+ async function errorBanner(): Promise<{ banner: string | null; sinceLast: SinceLast | null }> {
792
+ const buffered = timeline.buffer();
793
+ const liveNewest = buffered.length > 0 ? buffered[buffered.length - 1].t : null;
794
+ const state = watermark.get();
795
+
796
+ if (liveNewest === null) {
797
+ // Nothing buffered at all yet — there is nothing to report on, and
798
+ // nothing to seed either (there is no `t` to seed it to). Left null,
799
+ // so the first call that actually has something buffered is the one
800
+ // that decides whether it is worth reporting.
801
+ return { banner: null, sinceLast: null };
802
+ }
803
+ if (state.lastReportedErrorT !== null && liveNewest <= state.lastReportedErrorT) {
804
+ return { banner: null, sinceLast: null };
805
+ }
806
+
807
+ // `lastReportedErrorT === null` (this process has never reported
808
+ // anything) is treated as "everything currently buffered counts as
809
+ // unreported" rather than silently seeding to now — a call whose very
810
+ // first look at the world finds an error already sitting there should
811
+ // say so, not swallow it just because no earlier call happened to check
812
+ // first. `from: 0` reaches back to the start of whatever this process
813
+ // can see (the live buffer, widened by `mergeWithDisk`'s own disk
814
+ // fallback), the same "no prior context" floor `resolveWindow`'s own
815
+ // default uses.
816
+ const from = state.lastReportedErrorT === null ? 0 : state.lastReportedErrorT + 1;
817
+ const merged = await mergeWithDisk(from, liveNewest);
818
+ const events = merged.events as unknown as DeviceEvent[];
819
+ if (events.length === 0) {
820
+ await watermark.recordReportedErrorT(liveNewest);
821
+ return { banner: null, sinceLast: null };
822
+ }
823
+
824
+ const profile = resolveProfile({
825
+ liveEvents: buffered,
826
+ windowTo: liveNewest,
827
+ sessionProfile: device.sessions?.currentMeta()?.profile ?? null,
828
+ hello: (device.hello as unknown as Record<string, unknown>) ?? null,
829
+ });
830
+ const trace = buildTrace({
831
+ scenario: "since-last-banner",
832
+ events,
833
+ hello: (device.hello as unknown as Record<string, unknown>) ?? null,
834
+ durationMs: liveNewest - from,
835
+ withEvents: false,
836
+ profile,
837
+ });
838
+ const errorFindings = trace.findings.filter((f) => f.severity === "error");
839
+ await watermark.recordReportedErrorT(liveNewest);
840
+ if (errorFindings.length === 0) {
841
+ return { banner: null, sinceLast: null };
842
+ }
843
+
844
+ return {
845
+ banner: buildBanner(errorFindings),
846
+ sinceLast: {
847
+ errors: errorFindings.reduce((sum, f) => sum + (f.count ?? 1), 0),
848
+ firstAt: events[0].t,
849
+ lastAt: events[events.length - 1].t,
850
+ },
851
+ };
852
+ }
853
+
854
+ /**
855
+ * `ok()`'s actual work (see that function's own comment for why it is
856
+ * split out): opens the watermark for whichever session is current,
857
+ * builds the banner, prefixes it onto the summary, and attaches the
858
+ * structured `sinceLast` field to the payload — every payload here is a
859
+ * plain object, so the spread below always applies.
860
+ */
861
+ async function attachSinceLastAndBanner(
862
+ summary: string,
863
+ payload: unknown,
864
+ ): Promise<{ summary: string; payload: unknown }> {
865
+ await watermark.open(currentWatermarkDir());
866
+ const { banner, sinceLast } = await errorBanner();
867
+ const firstEver = pendingFirstEverNote;
868
+ pendingFirstEverNote = null;
869
+ const narrated = firstEver ? `${firstEver}${summary}` : summary;
870
+ const withBanner = banner ? `${banner}\n${narrated}` : narrated;
871
+ const withSinceLast =
872
+ payload !== null && typeof payload === "object" && !Array.isArray(payload)
873
+ ? { ...(payload as Record<string, unknown>), sinceLast }
874
+ : payload;
875
+ return { summary: withBanner, payload: withSinceLast };
876
+ }
877
+
878
+ // ---------------------------------------------------------------------------
879
+ // what to do next
880
+ // ---------------------------------------------------------------------------
881
+
882
+ /**
883
+ * The tool that shows a finding's evidence.
884
+ *
885
+ * Without this a finding is a dead end: it states a conclusion and leaves the
886
+ * agent to guess which of eleven tools substantiates it. Guessing is where the
887
+ * wandering starts, so each finding names its own next call.
888
+ */
889
+ const FOLLOW_UP: Record<string, { tool: string; why: string }> = {
890
+ "db-on-main-thread": { tool: "blocking", why: "the queries, their SQL and how long each took" },
891
+ "main-thread-stall": { tool: "blocking", why: "the stack the main thread was sitting in" },
892
+ "http-failed": { tool: "inflight", why: "the failed calls with status and body previews" },
893
+ "frames-dropped": { tool: "frames", why: "which phase dominated the janky frames" },
894
+ "blocking-gc": {
895
+ tool: "timeline",
896
+ why: 'what allocated around each collection (kinds: ["gc"])',
897
+ },
898
+ "trim-memory": {
899
+ tool: "timeline",
900
+ why: 'the memory series around the trim (kinds: ["memory"])',
901
+ },
902
+ "work-retried": { tool: "inflight", why: "the jobs and their attempt counts" },
903
+ "recompose-hotspot": {
904
+ tool: "recompositions",
905
+ why: "the per-node counts and the state keys written just before",
906
+ },
907
+ };
908
+
909
+ function withFollowUp(finding: Finding) {
910
+ const next = FOLLOW_UP[finding.id];
911
+ return next
912
+ ? { ...finding, next: { tool: next.tool, window: "quote `window` above", shows: next.why } }
913
+ : finding;
914
+ }
915
+
916
+ // ---------------------------------------------------------------------------
917
+ // tools
918
+ // ---------------------------------------------------------------------------
919
+
920
+ server.registerTool(
921
+ "porthole_status",
922
+ {
923
+ title: "Porthole status",
924
+ description:
925
+ "Whether the porthole is connected to a running app, which collectors are active, and what to " +
926
+ "do if it is not. Call this when another tool reports it cannot reach the device.\n\n" +
927
+ "This tool answers 'is it plugged in', not 'is anything wrong'. For that, call `findings`.\n\n" +
928
+ "Also carries `exits`: the most recent process deaths Android recorded for this app, so " +
929
+ "'why did it just die' is answerable on the first call after a crash, not a tool an agent " +
930
+ "has to know to reach for.",
931
+ inputSchema: {
932
+ // GRA-188: `exits.recent` prints both an epoch-milliseconds
933
+ // `timestamp` and an ISO-8601 `at` for the same instant (device
934
+ // pass, 2026-09-15 — an agent's obvious next move, quoting the
935
+ // printed timestamp straight back, used to fail validation whenever
936
+ // it reached for `at`). Both forms are accepted here now; either
937
+ // one round-trips with no conversion the caller has to think of.
938
+ exitTrace: z
939
+ .union([z.number().int().positive(), z.string()])
940
+ .optional()
941
+ .describe(
942
+ "Fetch the full redacted ANR/native-crash trace for one entry in `exits` — pass either " +
943
+ "that entry's `timestamp` (epoch milliseconds) or its `at` (ISO-8601) verbatim; both " +
944
+ "are accepted and converted. Capped at 256 KB by the runtime, with a note in the text " +
945
+ "if it was truncated. Omit this to just see the `exits` summary.",
946
+ ),
947
+ },
948
+ annotations: { readOnlyHint: true },
949
+ },
950
+ async ({ exitTrace }): Promise<ToolResult> => {
951
+ // GRA-119 AC5: name which SDK and which project root this run resolved
952
+ // to, and where each came from, so "adb resolved to the wrong SDK" is
953
+ // something this tool can actually diagnose instead of something an
954
+ // agent has to take on faith. resolveSdkDir()/resolveProjectRoot() in
955
+ // adb.ts already compute both; this just reports them.
956
+ const sdkDir = resolveSdkDir();
957
+ const projectRoot = resolveProjectRoot();
958
+ // GRA-157: DeviceClient now has a "handshaking" ConnectionState for the
959
+ // gap between the socket connecting and hello resolving, so this reads
960
+ // `device.state` alone — pendingMessage() names the disconnected and
961
+ // handshaking stories the same way `findings` does (AC3), and returns
962
+ // null only when state === "connected", which now guarantees `hello`
963
+ // is set, so the non-null assertion below is the invariant, not a hope.
964
+ const pending = device.pendingMessage();
965
+ const bufferedEvents = timeline.buffer().length;
966
+ // GRA-163: same structured field findings and what_was_happening carry
967
+ // — null once connected, otherwise the last confirmed process and when
968
+ // it exited, so an agent reading any of the three tools' JSON alone
969
+ // sees the same fact. QA round 1: `exitedProcessNotice()` builds the
970
+ // matching sentence from `bufferedEvents` above, so it can never claim
971
+ // buffered data this tool is not itself reporting any.
972
+ const exitedProcess = exitedProcessField();
973
+ const notice = exitedProcessNotice(exitedProcess, bufferedEvents > 0, pending === null);
974
+ const exits = exitsSection();
975
+ const deathNotice = exitDeathNotice(exits.recent, pending === null);
976
+
977
+ // GRA-58: a missing `exitTrace` fails zod validation before the
978
+ // handler ever runs when it is a negative or non-integer number
979
+ // (`exitTrace` is `z.union([z.number().int().positive(), z.string()])`);
980
+ // a numeric shape reaching here may still name a timestamp the
981
+ // runtime has never heard of, hence the `found` field in what comes
982
+ // back rather than a thrown error.
983
+ //
984
+ // GRA-188: a *string* shape is new — `exits.recent` prints both an
985
+ // epoch-milliseconds `timestamp` and an ISO-8601 `at` for the same
986
+ // instant, and an agent quoting either back should work. `Date.parse`
987
+ // covers `at`'s own format and every other syntax that reasonably
988
+ // names an instant; a string that parses to nothing (empty, garbage)
989
+ // is refused right here, with one line, rather than reaching
990
+ // `device.request` with `NaN`.
991
+ let exitTraceMs: number | null = null;
992
+ if (typeof exitTrace === "string") {
993
+ const parsed = Date.parse(exitTrace);
994
+ if (!Number.isFinite(parsed)) {
995
+ return fail(
996
+ `exitTrace: ${JSON.stringify(exitTrace)} is not a valid epoch-milliseconds number or an ` +
997
+ "ISO-8601 timestamp.",
998
+ );
999
+ }
1000
+ exitTraceMs = parsed;
1001
+ } else if (exitTrace !== undefined) {
1002
+ exitTraceMs = exitTrace;
1003
+ }
1004
+
1005
+ let exitTraceResult: unknown = null;
1006
+ if (exitTraceMs !== null) {
1007
+ try {
1008
+ exitTraceResult = await device.request("exit_trace", { timestamp: exitTraceMs });
1009
+ } catch (error) {
1010
+ exitTraceResult = {
1011
+ timestamp: exitTraceMs,
1012
+ found: false,
1013
+ error: error instanceof Error ? error.message : String(error),
1014
+ };
1015
+ }
1016
+ }
1017
+
1018
+ const payload = {
1019
+ state: device.state,
1020
+ host: HOST,
1021
+ port: PORT,
1022
+ app: device.hello,
1023
+ timelineUi: timeline.isRunning() ? timeline.url() : null,
1024
+ bufferedEvents,
1025
+ lastError: device.lastError,
1026
+ // GRA-96: null on a healthy handshake, otherwise the same sentence
1027
+ // `summary` uses below — reported in the payload too so a caller
1028
+ // reading structured data (not just the text) can branch on it
1029
+ // without string-matching `summary`.
1030
+ protocolMismatch: device.protocolMismatch,
1031
+ sdkDir: sdkDir.directory,
1032
+ sdkDirSource: sdkDir.source,
1033
+ projectRoot: projectRoot.directory,
1034
+ projectRootSource: projectRoot.source,
1035
+ exitedProcess,
1036
+ exits,
1037
+ exitTrace: exitTraceResult,
1038
+ };
1039
+ // GRA-96: a protocol mismatch takes priority over the normal "here is
1040
+ // what's connected" sentence — hello did land and the socket is fine,
1041
+ // but the one thing worth saying is that the two sides disagree on the
1042
+ // wire format, not the collector list a mismatched build may not even
1043
+ // be able to report honestly. This is what turns AC1's "specific,
1044
+ // actionable message... not a generic failure" into the actual summary
1045
+ // text an agent reads, rather than a field it has to know to check.
1046
+ const summary =
1047
+ notice +
1048
+ deathNotice +
1049
+ (pending ??
1050
+ device.protocolMismatch ??
1051
+ `Connected to ${device.hello!.packageName} on ${device.hello!.device} ` +
1052
+ `(API ${device.hello!.sdkInt}). Collectors: ${device.hello!.collectors.join(", ")}.`);
1053
+ return ok(summary, payload);
1054
+ },
1055
+ );
1056
+
1057
+ server.registerTool(
1058
+ "findings",
1059
+ {
1060
+ title: "What is wrong right now",
1061
+ description:
1062
+ "Start here. Everything the porthole can currently say is wrong, ranked, each with how " +
1063
+ "strongly it can be claimed and which tool shows its evidence.\n\n" +
1064
+ "The other tools return measurements and leave the conclusion to you. This one draws the " +
1065
+ "conclusions the data actually supports, which is a shorter list than it looks: queries on " +
1066
+ "the main thread, stalls, failed calls, dropped frames, blocking collections, memory trims, " +
1067
+ "retried jobs, recomposition hotspots.\n\n" +
1068
+ "`confidence` is load-bearing and worth repeating to whoever reads your answer. 'observed' " +
1069
+ "means the device reported it: a query ran on the main thread, a frame missed its deadline. " +
1070
+ "'correlated' means two things happened close together, which is ordering and not " +
1071
+ "causation. Do not upgrade a correlated finding to a cause because it is the only one you " +
1072
+ "have.\n\n" +
1073
+ "`clippedMs` says how much of the window asked for fell outside what is still buffered. " +
1074
+ "Non-zero means part of the question was never examined, which is a different answer from " +
1075
+ "there being nothing there.\n\n" +
1076
+ "An empty list means nothing crossed a threshold in this window. It does not mean the app " +
1077
+ "is fast, and it does not mean the window contained the problem — check `window` against " +
1078
+ "the moment you care about before concluding anything from silence.",
1079
+ inputSchema: windowShape,
1080
+ annotations: { readOnlyHint: true },
1081
+ },
1082
+ async ({ sinceMs, from, to, since }): Promise<ToolResult> => {
1083
+ const span = await resolveWindowSince({ sinceMs, from, to, since });
1084
+ if (!span) {
1085
+ // resolveWindow returns null whenever the ring is empty, which is not
1086
+ // the same thing as the device being unreachable — hello can have
1087
+ // landed seconds ago with nothing collected yet. Printing the full
1088
+ // troubleshooting wall in that case sends the first call after every
1089
+ // install chasing a socket that was never the problem.
1090
+ //
1091
+ // GRA-157: "connected" here is the loose sense porthole_status also
1092
+ // uses — the socket is up, whether or not hello has landed — because
1093
+ // that is the fact an agent deciding whether to keep polling
1094
+ // actually wants. GRA-162: isAttached() replaces the inline
1095
+ // `=== "handshaking" || === "connected"` so a fifth ConnectionState
1096
+ // fails `tsc` here instead of silently reading as not-connected.
1097
+ // GRA-163: this loose sense is safe only because there is no ring
1098
+ // content here to mislabel — an empty ring has nothing to claim is
1099
+ // live. The non-empty branch below asks a stricter question; see its
1100
+ // own comment for why the two cannot share one boolean.
1101
+ const connected = isAttached(device.state);
1102
+ const pending = device.pendingMessage();
1103
+ // GRA-163 QA round 1: called on this branch too now — an empty ring
1104
+ // can still have a `lastExited` behind it (reconnect, hello clears
1105
+ // the ring, then the new process dies before emitting anything),
1106
+ // and `porthole_status` was already reporting that unconditionally
1107
+ // while this branch reported nothing at all, which is the exact
1108
+ // cross-tool disagreement this ticket exists to remove.
1109
+ // `hasBufferedData: false` because this branch is reached only when
1110
+ // the ring is empty — the prose must not claim otherwise.
1111
+ const exitedProcess = exitedProcessField();
1112
+ // `connected: pending === null` (the strict sense) here, not the
1113
+ // loose `connected` above — inert in practice since
1114
+ // `hasBufferedData: false` short-circuits both of
1115
+ // exitedProcessNotice()'s non-exited cases to "", but kept correct
1116
+ // rather than passing whichever local happens to be in scope.
1117
+ const notice = exitedProcessNotice(exitedProcess, false, pending === null);
1118
+ if (pending !== null) {
1119
+ return ok(notice + pending, { window: null, findings: [], connected, exitedProcess });
1120
+ }
1121
+ const summary = `Connected to ${device.hello!.packageName}, nothing buffered yet. Ask again in a moment.`;
1122
+ return ok(notice + summary, { window: null, findings: [], connected, exitedProcess });
1123
+ }
1124
+
1125
+ const buffered = timeline.buffer();
1126
+
1127
+ // GRA-189: `since: "last"` resolved to a genuinely empty window — the
1128
+ // watermark this call would advance from has nothing newer past it,
1129
+ // and no previous `findings` digest to re-ask (see `nothingNew`'s own
1130
+ // comment on `resolveWindowSince`). Running the analyser over that
1131
+ // reports "0s examined," the least useful possible answer to exactly
1132
+ // the question `since: "last"` exists to shortcut — so this skips the
1133
+ // analysis entirely rather than dressing up an empty result. This is
1134
+ // also the device case: a fresh MCP process that loads a watermark an
1135
+ // earlier process left on disk lands here whenever nothing has
1136
+ // arrived since, and the prose must say "nothing new," not "first
1137
+ // call" — `firstEver` is false, because the watermark was not empty,
1138
+ // it simply has nothing new past it.
1139
+ if (span.nothingNew) {
1140
+ const pending = device.pendingMessage();
1141
+ const connected = pending === null;
1142
+ const exitedProcess = exitedProcessField();
1143
+ const notice = exitedProcessNotice(exitedProcess, buffered.length > 0, connected);
1144
+ return ok(
1145
+ notice +
1146
+ `Nothing new has arrived since the last call, which examined up to t=${span.to}. ` +
1147
+ 'Use since: "all", or an explicit window, for the whole picture.',
1148
+ {
1149
+ window: { from: span.from, to: span.to, ms: 0 },
1150
+ eventsExamined: 0,
1151
+ findings: [],
1152
+ connected,
1153
+ exitedProcess,
1154
+ },
1155
+ );
1156
+ }
1157
+
1158
+ // GRA-53: merges the live buffer with whatever sessions on disk
1159
+ // overlap the window, deduplicated and sorted — see
1160
+ // `fillWindowFromDisk`'s own doc comment in sessions.ts. `events` here
1161
+ // used to be the live ring alone; it is now the same merge
1162
+ // `what_was_happening` already uses, so a finding can be produced from
1163
+ // a window that spans an MCP-server restart, not only from whatever
1164
+ // survived in memory.
1165
+ const merged = await mergeWithDisk(span.from, span.to);
1166
+ const events = merged.events as unknown as DeviceEvent[];
1167
+
1168
+ // GRA-163: a non-empty ring is not, on its own, proof the events in it
1169
+ // are from what is running now — the ring only clears on a new hello
1170
+ // (timeline.ts), so anything buffered while state has not reached
1171
+ // "connected" could just as easily be a previous session's leftovers.
1172
+ // `device.pendingMessage()` is the shared decision point GRA-157 built
1173
+ // for exactly this question, and it used to be reachable only from the
1174
+ // empty-ring branch above (`!span`) — the one input where a stale,
1175
+ // still-buffered ring cannot appear at all. Calling it here too is
1176
+ // what makes `connected` strict (true only once hello has actually
1177
+ // landed for the session that is being reported on) instead of the
1178
+ // loose isAttached() sense used above, where handshaking read as
1179
+ // attached even when the ring's contents predated the handshake. That
1180
+ // conflation was the measured bug: 34 hardware samples caught this
1181
+ // tool reporting `connected: true` about a dead process during a later
1182
+ // handshake, because handshaking alone was treated as good enough.
1183
+ const pending = device.pendingMessage();
1184
+ const connected = pending === null;
1185
+ // Structured, not just prose (per the founder-pending assumption this
1186
+ // ticket is built on): null once connected, otherwise the process
1187
+ // `device` last confirmed and when it exited, so an agent can branch
1188
+ // on this without parsing the summary text. `hasBufferedData: true`
1189
+ // because this is the non-empty branch — the ring genuinely has
1190
+ // events, even if the requested window clips around them.
1191
+ const exitedProcess = exitedProcessField();
1192
+ const notice = exitedProcessNotice(exitedProcess, true, connected);
1193
+
1194
+ // Asking about a moment the ring no longer holds returns nothing, which is
1195
+ // indistinguishable from a moment when nothing happened. They are opposite
1196
+ // answers and only one of them is about the app.
1197
+ const liveOldest = buffered[0]?.t ?? span.from;
1198
+ const liveNewest = buffered[buffered.length - 1]?.t ?? span.to;
1199
+ // GRA-53: `clippedMs` is now a coverage question, answered against
1200
+ // `merged.coveredFrom`/`coveredTo` (live buffer bounds unioned with
1201
+ // every overlapping session's own recorded extent) rather than against
1202
+ // the live buffer's bounds alone — see `fillWindowFromDisk`'s doc
1203
+ // comment on why this must NOT be derived from which events actually
1204
+ // matched: a quiet stretch inside a recorded session must read as
1205
+ // covered, not as clipped, just because nothing happened in it. When
1206
+ // neither the buffer nor any session on disk overlaps the window at
1207
+ // all, `coveredFrom`/`coveredTo` are null and the whole window is
1208
+ // honestly unrecorded.
1209
+ // GRA-54: the same function `save_moment`'s trace carries this exact
1210
+ // number under, so the two cannot drift apart the way two hand-rolled
1211
+ // copies of this formula eventually would (see clippedMsOf's own
1212
+ // comment in sessions.ts).
1213
+ const clipped = clippedMsOf(span.from, span.to, merged.coveredFrom, merged.coveredTo);
1214
+ // GRA-185: searched over the live ring (`buffered`), not `events`
1215
+ // (which is windowed to `span`) — a profile event before `span.from`
1216
+ // must still count. See `resolveProfile`'s own doc comment.
1217
+ const profile = resolveProfile({
1218
+ liveEvents: buffered,
1219
+ windowTo: span.to,
1220
+ sessionProfile: device.sessions?.currentMeta()?.profile ?? null,
1221
+ hello: (device.hello as unknown as Record<string, unknown>) ?? null,
1222
+ });
1223
+ const trace = buildTrace({
1224
+ // The same analyser the headless capture runs, pointed at the live
1225
+ // buffer instead of a recorded scenario. One analyser, so a finding
1226
+ // means the same thing in CI as it does in an editor.
1227
+ scenario: "live",
1228
+ events,
1229
+ hello: (device.hello as unknown as Record<string, unknown>) ?? null,
1230
+ durationMs: span.ms,
1231
+ withEvents: false,
1232
+ profile,
1233
+ });
1234
+
1235
+ const findings = trace.findings.map(withFollowUp);
1236
+
1237
+ // GRA-55: classified against whatever the *previous* findings call
1238
+ // left in the watermark, before this call's own digest overwrites it
1239
+ // — order matters here, `recordDigest` below must come after reading
1240
+ // `previousDigest`, not before.
1241
+ const previousDigest = watermark.get().digest;
1242
+ const classified = classify(findings, previousDigest, span.sinceLast, {
1243
+ from: span.from,
1244
+ to: span.to,
1245
+ });
1246
+ await watermark.recordDigest({
1247
+ findings: trace.findings.map((f) => ({ id: f.id, count: f.count ?? 1 })),
1248
+ window: { from: span.from, to: span.to },
1249
+ sinceLast: span.sinceLast,
1250
+ });
1251
+ const classificationNote = classified.counts
1252
+ ? ` (${classificationSummary(classified.counts)})`
1253
+ : classified.skippedNote
1254
+ ? ` (${classified.skippedNote})`
1255
+ : "";
1256
+ const payload = {
1257
+ window: { from: span.from, to: span.to, ms: span.ms },
1258
+ // The merged (disk + memory) recorded extent, clipped to the window
1259
+ // — distinct from `buffered` below, which stays the live ring's own
1260
+ // account of itself.
1261
+ examined: { from: merged.coveredFrom ?? span.from, to: merged.coveredTo ?? span.from },
1262
+ buffered: { from: liveOldest, to: liveNewest, events: buffered.length },
1263
+ clippedMs: clipped,
1264
+ eventsExamined: events.length,
1265
+ metrics: trace.metrics,
1266
+ // Classified findings (new/ongoing/resolved, per `classify()`'s own
1267
+ // comment on when that runs) rather than the plain list — `resolved`
1268
+ // entries can make this longer than `findings.length` below, which
1269
+ // stays keyed on what is *currently* true, not on what changed.
1270
+ findings: classified.findings,
1271
+ connected,
1272
+ exitedProcess,
1273
+ };
1274
+
1275
+ const shortfall = clipped.start + clipped.end;
1276
+ const missing =
1277
+ shortfall > 0
1278
+ ? ` ${Math.round(shortfall / 100) / 10}s of the window asked for is older or newer than ` +
1279
+ "anything buffered, so it was not examined at all."
1280
+ : "";
1281
+
1282
+ if (findings.length === 0) {
1283
+ return ok(
1284
+ notice +
1285
+ (shortfall > span.ms * 0.5
1286
+ ? `Almost none of that window is in the buffer${missing} This is not a quiet app; ` +
1287
+ "it is a question the buffer cannot answer."
1288
+ : `Nothing crossed a threshold in the ${Math.round(span.ms / 1000)}s examined ` +
1289
+ `(${events.length} events). That is not the same as the app being fast.${missing}`) +
1290
+ classificationNote,
1291
+ payload,
1292
+ );
1293
+ }
1294
+
1295
+ const worst = trace.findings[0];
1296
+ const bySeverity = trace.findings.reduce<Record<string, number>>((acc, f) => {
1297
+ acc[f.severity] = (acc[f.severity] ?? 0) + 1;
1298
+ return acc;
1299
+ }, {});
1300
+ const tally = Object.entries(bySeverity)
1301
+ .map(([severity, n]) => `${n} ${severity}`)
1302
+ .join(", ");
1303
+
1304
+ return ok(
1305
+ notice +
1306
+ `${findings.length} finding(s) over ${Math.round(span.ms / 1000)}s (${tally}). ` +
1307
+ `Worst: ${worst.title} [${worst.confidence}].${missing}${classificationNote}`,
1308
+ payload,
1309
+ );
1310
+ },
1311
+ );
1312
+
1313
+ server.registerTool(
1314
+ "system_context",
1315
+ {
1316
+ title: "What the rest of the device was doing",
1317
+ description:
1318
+ "Thermal state, CPU governor and clock, the busiest processes, and system memory pressure. " +
1319
+ "Read straight off the device over adb.\n\n" +
1320
+ "This is the half Porthole cannot see. It watches one process, so when `blocking` reports a " +
1321
+ "stall whose stack bottoms out in a native read, or `frames` blames swapBuffers, the reason " +
1322
+ "is usually below the app and none of the other tools can reach it. A throttled device, a " +
1323
+ "governor holding the cores down, or another process eating the CPU explains a regression " +
1324
+ "that no code change accounts for.\n\n" +
1325
+ "Reports only what it read. Values are current, not historical — this says what is true now, " +
1326
+ "not what was true during a window you are investigating, so take it while the problem is " +
1327
+ "happening. Sources it could not parse are listed in `unavailable` rather than omitted, " +
1328
+ "because a missing thermal reading and a cool device are not the same thing.\n\n" +
1329
+ "It draws no conclusions. Two cores below maximum is a fact; that it is why your app is " +
1330
+ "slow is a guess, and this tool does not know what your app was doing.",
1331
+ inputSchema: {
1332
+ serial: z
1333
+ .string()
1334
+ .optional()
1335
+ .describe("Device serial, when more than one is attached. `adb devices` lists them."),
1336
+ },
1337
+ annotations: { readOnlyHint: true },
1338
+ },
1339
+ async ({ serial }): Promise<ToolResult> => {
1340
+ const unavailable: SystemContext["unavailable"] = [];
1341
+
1342
+ const read = (source: string, args: string[]): string | null => {
1343
+ const result = runAdb(args, serial);
1344
+ if (!result.ok) {
1345
+ unavailable.push({ source, reason: result.output.split("\n")[0].slice(0, 160) });
1346
+ return null;
1347
+ }
1348
+ return result.output;
1349
+ };
1350
+
1351
+ const thermalOut = read("thermalservice", ["shell", "dumpsys", "thermalservice"]);
1352
+ const cpuOut = read("cpufreq", ["shell", CPU_PROBE]);
1353
+ const topOut = read("cpuinfo", ["shell", "dumpsys", "cpuinfo"]);
1354
+ const memOut = read("meminfo", ["shell", "dumpsys", "meminfo"]);
1355
+
1356
+ const context: SystemContext = {
1357
+ thermal: thermalOut === null ? null : parseThermal(thermalOut),
1358
+ cpu: cpuOut === null ? null : parseCpu(cpuOut),
1359
+ top: topOut === null ? [] : parseTop(topOut),
1360
+ memory: memOut === null ? null : parseMemory(memOut),
1361
+ unavailable,
1362
+ };
1363
+
1364
+ return ok(describeSystem(context), context);
1365
+ },
1366
+ );
1367
+
1368
+ server.registerTool(
1369
+ "ask_system_trace",
1370
+ {
1371
+ title: "Ask a system trace about a window",
1372
+ description:
1373
+ "Runs a fixed set of questions against a recorded trace, scoped to one window and one " +
1374
+ "process, and returns findings in the same vocabulary as everything else here.\n\n" +
1375
+ "It performs, without a person, the steps someone otherwise does by hand in a trace " +
1376
+ "viewer: find the moment worth looking at, drag out the window, pick the app out of the " +
1377
+ "process list, and export. Porthole already holds all four — the window comes from a " +
1378
+ "finding, the package from the handshake with the device — which is the only reason this " +
1379
+ "can be automated at all.\n\n" +
1380
+ "What it is for is ruling causes out. `findings` can say a frame was late and that " +
1381
+ "composition dominated it. It cannot say whether the device was starving the app of CPU, " +
1382
+ "blocking it on I/O, or compiling its own bytecode in the background. Answering no to " +
1383
+ "each of those is what turns a suspicion into a conclusion, and answering yes to one " +
1384
+ "means the app's own work was never the whole story.\n\n" +
1385
+ "Deliberately not a SQL interface. The questions are fixed, because an agent handed a " +
1386
+ "hundred tables and no guidance assembles an answer from whichever guess came back " +
1387
+ "non-empty — which is the failure this whole surface was reshaped to avoid.\n\n" +
1388
+ "Needs `trace_processor_shell`, which is not bundled — it is a large platform-specific " +
1389
+ "binary — but is fetched on request: `./gradlew portholeTraceProcessor` downloads the " +
1390
+ "pinned release, checks its SHA-256 and caches it where this tool looks.",
1391
+ inputSchema: {
1392
+ trace: z.string().describe("Path to a .pftrace, as returned by capture_system_trace."),
1393
+ from: z
1394
+ .number()
1395
+ .int()
1396
+ .optional()
1397
+ .describe("Window start on the device uptime clock. Quote a finding's `window`."),
1398
+ to: z.number().int().optional().describe("Window end, same clock."),
1399
+ packageName: z
1400
+ .string()
1401
+ .optional()
1402
+ .describe("Defaults to the app the porthole is attached to."),
1403
+ traceProcessor: z.string().optional().describe("Path to trace_processor_shell."),
1404
+ },
1405
+ annotations: { readOnlyHint: true },
1406
+ },
1407
+ async ({ trace, from, to, packageName, traceProcessor }): Promise<ToolResult> => {
1408
+ // GRA-157: connection state checked before the trace_processor lookup,
1409
+ // not after. A device still mid-handshake is not the caller's fault and
1410
+ // not fixed by anything on this machine, so naming that first means a
1411
+ // caller who has not connected yet is never told to go install a
1412
+ // binary when the real, more immediate blocker is the device.
1413
+ const app = packageName ?? device.hello?.packageName;
1414
+ if (!app) {
1415
+ // "Connect to the app" was printed even while the socket was already
1416
+ // connected and just waiting on hello — telling someone to do a
1417
+ // thing that is already in progress. Naming the handshake instead of
1418
+ // the generic advice is the whole fix; the advice itself (pass
1419
+ // `packageName`) still applies either way.
1420
+ // GRA-162: isHandshaking() instead of `=== "handshaking"` — same
1421
+ // exhaustiveness argument as isAttached() above.
1422
+ const because =
1423
+ isHandshaking(device.state)
1424
+ ? "the app is still waiting on its first check-in — try again in a moment, "
1425
+ : "connect to the app, ";
1426
+ return fail(
1427
+ `No package to scope to: ${because}or pass \`packageName\` — without it the questions ` +
1428
+ "answer for the whole device, which is a different question.",
1429
+ );
1430
+ }
1431
+
1432
+ const binary = traceProcessor ?? process.env.PORTHOLE_TRACE_PROCESSOR ?? findTraceProcessor();
1433
+ if (!binary) {
1434
+ return fail(
1435
+ "No trace_processor_shell found. Run `./gradlew portholeTraceProcessor` in the app's " +
1436
+ "project: it downloads the pinned Perfetto release, verifies its checksum and caches " +
1437
+ "it where this tool looks, so nothing further needs configuring. An existing copy " +
1438
+ "works too — set PORTHOLE_TRACE_PROCESSOR or pass `traceProcessor`. Either way the " +
1439
+ "trace itself is already readable at ui.perfetto.dev.",
1440
+ );
1441
+ }
1442
+
1443
+ // The window arrives in Porthole's clock and the trace is stamped in the
1444
+ // boot clock, so it has to be converted before it means anything here.
1445
+ const events = timeline.buffer();
1446
+ const span = resolveWindow({ from, to });
1447
+ if (!span) {
1448
+ // GRA-154 (absorbed into GRA-157 AC7): this used to say "Nothing
1449
+ // buffered" unconditionally — a third, different vocabulary from
1450
+ // findings/what_was_happening for the identical empty-ring
1451
+ // condition. pendingMessage() brings the wording in line with
1452
+ // theirs. This one stays an error result rather than switching to
1453
+ // `ok` like the other two: there is genuinely no window here to ask
1454
+ // trace_processor about, nothing partial to return the way an empty
1455
+ // findings list or a "nothing happened here" moment still can.
1456
+ const pending = device.pendingMessage();
1457
+ return fail(
1458
+ pending ??
1459
+ "Connected, but nothing buffered yet, so there is no window to scope the trace to. " +
1460
+ "Ask again in a moment.",
1461
+ );
1462
+ }
1463
+ // GRA-113: the one conversion, through moment.ts's toBoot — this used
1464
+ // to read whichever `clocks` sample `events.find()` happened to
1465
+ // return first and apply it to both bounds, the same open-coded bug
1466
+ // that ticket fixed in timeline.ts. toBoot picks the sample actually
1467
+ // in force at each boundary separately (so a sleep that happened
1468
+ // between `from` and `to` is reflected correctly instead of averaged
1469
+ // away), and hands back the offset it used so this tool can still
1470
+ // report `sleepMs` the way its payload always has.
1471
+ const bootFrom = toBoot(events, span.from);
1472
+ const bootTo = toBoot(events, span.to);
1473
+
1474
+ const { findings: traceFindings, unanswered } = await askTrace({
1475
+ binary,
1476
+ trace,
1477
+ packageName: app,
1478
+ fromNs: bootFrom.ns,
1479
+ toNs: bootTo.ns,
1480
+ });
1481
+
1482
+ const findings = traceFindings.map(withFollowUp);
1483
+ const payload = {
1484
+ trace,
1485
+ app,
1486
+ // bootTo's offset, not bootFrom's: if the device slept between the
1487
+ // two, the more recent sample is the more representative one to
1488
+ // report.
1489
+ window: { from: span.from, to: span.to, sleepMs: bootTo.sleepMs },
1490
+ asked: QUESTIONS.map((q) => q.asks),
1491
+ unanswered,
1492
+ findings,
1493
+ };
1494
+
1495
+ const failures = unanswered;
1496
+ const summary = findings.length
1497
+ ? `${findings.length} finding(s) from the trace. ${findings[0].title}.`
1498
+ : "The trace had nothing to add about that window.";
1499
+ return ok(
1500
+ summary + (failures.length ? ` ${failures.length} question(s) failed.` : ""),
1501
+ payload,
1502
+ );
1503
+ },
1504
+ );
1505
+
1506
+ server.registerTool(
1507
+ "capture_system_trace",
1508
+ {
1509
+ title: "Record a Perfetto trace",
1510
+ description:
1511
+ "Records a system trace on the device, pulls it to disk, and returns the path. Does not " +
1512
+ "return the trace itself: a ten-second capture is tens of megabytes of protobuf, and it is " +
1513
+ "not something to read — it is something to open.\n\n" +
1514
+ "The reason to take one here rather than by hand is that the app's own spans are already " +
1515
+ "inside it. The runtime writes navigations, HTTP calls, queries and main-thread stalls as " +
1516
+ "atrace sections, so the capture arrives annotated with what the app was doing and not only " +
1517
+ "what the kernel was doing. The result says how many Porthole labels it found, which is how " +
1518
+ "you know the annotation actually happened.\n\n" +
1519
+ "Use it when Porthole has found something it cannot explain — a stall whose stack bottoms " +
1520
+ "out below the app, or jank blamed on swapBuffers — and you need to see what the rest of " +
1521
+ "the system was doing at that moment. `findings` gives you the window worth looking at; " +
1522
+ "this gives you the depth at it.\n\n" +
1523
+ "`restartApp` force-stops and relaunches the target app right after the capture starts — " +
1524
+ "needed on builds that only read the app trace tag at process start (seen on a Pixel 9 " +
1525
+ "Pro Fold, Android 17), where an already-running process's own sections would otherwise " +
1526
+ "be silently missing, at the cost of the trace containing a cold start.\n\n" +
1527
+ "Blocks for the requested duration. Reproduce the problem while it runs.",
1528
+ inputSchema: {
1529
+ seconds: z
1530
+ .number()
1531
+ .int()
1532
+ .positive()
1533
+ .max(120)
1534
+ .optional()
1535
+ .describe("How long to record. Default 10."),
1536
+ categories: z
1537
+ .array(z.string())
1538
+ .optional()
1539
+ .describe(
1540
+ "atrace categories. Defaults to a set aimed at jank. `app` is always included, " +
1541
+ "since without it none of Porthole's own sections are recorded.",
1542
+ ),
1543
+ outputDir: z
1544
+ .string()
1545
+ .optional()
1546
+ .describe("Where to write it. Defaults to .porthole/traces under the working directory."),
1547
+ packages: z
1548
+ .array(z.string())
1549
+ .optional()
1550
+ .describe(
1551
+ "Packages whose app-tag sections to record. Defaults to the app the porthole is " +
1552
+ "attached to. Without one, the trace has no Porthole slices in it.",
1553
+ ),
1554
+ serial: z.string().optional().describe("Device serial, when more than one is attached."),
1555
+ restartApp: z
1556
+ .boolean()
1557
+ .optional()
1558
+ .default(false)
1559
+ .describe(
1560
+ "Force-stop and relaunch the target app right after the capture starts, since on " +
1561
+ "builds that only read the app trace tag at process start (seen on a Pixel 9 Pro " +
1562
+ "Fold, Android 17) an already-running process's own sections never appear — the " +
1563
+ "trade-off is a cold start inside the trace. The package is the first entry of " +
1564
+ "`packages`, or the attached app when `packages` is omitted. Default false.",
1565
+ ),
1566
+ },
1567
+ annotations: { readOnlyHint: false },
1568
+ },
1569
+ async ({ seconds, categories, outputDir, packages, serial, restartApp }): Promise<ToolResult> => {
1570
+ // GRA-157: `device.hello ? [...] : []` used to fall through to an
1571
+ // unscoped capture — silently, with nothing in the result saying so —
1572
+ // whenever this landed in the handshake window, since state was
1573
+ // already "connected" there under the old model. An unscoped capture
1574
+ // has none of Porthole's own slices in it, which defeats the point of
1575
+ // this tool, so when we can name the actual reason (a hello is
1576
+ // genuinely on its way) this fails and says so instead of guessing.
1577
+ // Fully disconnected keeps the old permissive behaviour: apps: []
1578
+ // captures the whole device, same as always.
1579
+ // GRA-162: isHandshaking() instead of `=== "handshaking"`.
1580
+ if (!packages?.length && isHandshaking(device.state)) {
1581
+ return fail(
1582
+ "Still waiting on the app's first check-in, so there is no package to scope this " +
1583
+ "capture to yet. Try again in a moment, or pass `packages` explicitly to capture " +
1584
+ "unscoped right now.",
1585
+ );
1586
+ }
1587
+ // Default to whatever app the porthole is attached to: that is the one
1588
+ // whose sections are worth recording, and asking for it again is friction.
1589
+ const apps = packages?.length ? packages : device.hello ? [device.hello.packageName] : [];
1590
+ const plan = planCapture({ seconds, categories, apps });
1591
+ const adbCallOptions: AdbCallOptions = { serial, env: adbEnv, binary: adbBinary };
1592
+
1593
+ // GRA-89: async, awaited spawn for all three adb calls below, not
1594
+ // `runAdb`'s `spawnSync` — that used to freeze the whole MCP server
1595
+ // for the entire recording (up to two minutes at this tool's own
1596
+ // 120s maximum): nothing read the device socket, nothing answered
1597
+ // another tool call, and the timeline WebSocket went silent for as
1598
+ // long as each call took. The recording gets its own, longer timeout
1599
+ // (the plan's own duration plus room for adb's own startup and
1600
+ // teardown) rather than `runAdbAsync`'s short default, which exists
1601
+ // for calls — the pull, the cleanup — that are supposed to be quick.
1602
+ //
1603
+ // GRA-186: not awaited here any more. `restartApp: true` has to act
1604
+ // *during* this recording, not after it — awaiting first would mean
1605
+ // "restarting" only once the whole window is already over, which is
1606
+ // the exact bug this option exists to work around.
1607
+ const recordingPromise = runAdbAsync(captureArgs(plan), {
1608
+ ...adbCallOptions,
1609
+ timeoutMs: plan.seconds * 1000 + CAPTURE_ADB_TIMEOUT_BUFFER_MS,
1610
+ onProgress: (elapsedMs) =>
1611
+ process.stderr.write(
1612
+ `[porthole] capture_system_trace: recording, ${Math.round(elapsedMs / 1000)}s of ` +
1613
+ `${plan.seconds}s elapsed...\n`,
1614
+ ),
1615
+ });
1616
+
1617
+ // GRA-186: on a build that only reads ATRACE_TAG_APP at process
1618
+ // attach (Pixel 9 Pro Fold, Android 17), a process already running
1619
+ // when the session starts never picks the tag up — a process that
1620
+ // (re)starts after the session has begun does. Restarting here, once
1621
+ // `waitForCaptureToStart` has the best available signal that the
1622
+ // session is live, is the fix; see systrace.ts's zero-label sentence
1623
+ // for the diagnosis this is a remedy for.
1624
+ let restarted = false;
1625
+ const restartNotes: string[] = [];
1626
+ if (restartApp) {
1627
+ const target = apps[0];
1628
+ if (!target) {
1629
+ restartNotes.push(
1630
+ "Could not restart the app for this capture: no package is attached or named, so " +
1631
+ "there is nothing to restart.",
1632
+ );
1633
+ } else {
1634
+ await waitForCaptureToStart(plan.devicePath, adbCallOptions);
1635
+ const restartResult = await restartAppAsync(target, adbCallOptions);
1636
+ restarted = restartResult.ok;
1637
+ if (!restartResult.ok) {
1638
+ restartNotes.push(`Could not restart ${target} for this capture: ${restartResult.output}`);
1639
+ }
1640
+ }
1641
+ }
1642
+
1643
+ const recorded = await recordingPromise;
1644
+ if (!recorded.ok) {
1645
+ return fail(
1646
+ `Could not record: ${recorded.output}\n` +
1647
+ "On-device Perfetto needs Android 9 or newer, and the traced service must be running.",
1648
+ );
1649
+ }
1650
+
1651
+ // Default under .porthole/, which the project's gitignore already covers —
1652
+ // a multi-megabyte trace should not be a candidate for committing.
1653
+ const dir = resolve(outputDir ?? join(process.cwd(), ".porthole", "traces"));
1654
+ mkdirSync(dir, { recursive: true });
1655
+ const local = join(dir, plan.devicePath.split("/").pop() as string);
1656
+
1657
+ const pulled = await runAdbAsync(["pull", plan.devicePath, local], adbCallOptions);
1658
+ // Tidy up regardless: the device's trace directory is not ours to fill.
1659
+ await runAdbAsync(["shell", "rm", "-f", plan.devicePath], adbCallOptions);
1660
+
1661
+ if (!pulled.ok) return fail(`Recorded, but could not pull it: ${pulled.output}`);
1662
+
1663
+ const bytes = statSync(local).size;
1664
+ const result = {
1665
+ path: local,
1666
+ bytes,
1667
+ seconds: plan.seconds,
1668
+ categories: plan.categories,
1669
+ apps: plan.apps,
1670
+ restarted,
1671
+ // GRA-89: streamed in chunks by countPortholeLabels itself now, not
1672
+ // a whole-file readFileSync handed to it — see systrace.ts.
1673
+ portholeLabels: await countPortholeLabels(local),
1674
+ notes: [...plan.notes, ...restartNotes],
1675
+ };
1676
+ return ok(describeCapture(result), result);
1677
+ },
1678
+ );
1679
+
1680
+ server.registerTool(
1681
+ "save_moment",
1682
+ {
1683
+ title: "Save what just happened",
1684
+ description:
1685
+ "Turns a window of what already happened into a named trace file on disk, in exactly the " +
1686
+ "format `capture` writes — `porthole report` and `porthole compare` work on it with no " +
1687
+ "changes. For when the developer pokes the app, something bad happens, and only then wants " +
1688
+ "to keep it: no need to reproduce it again with a recording running.\n\n" +
1689
+ "Give it a window the way every other windowed tool takes one — `sinceMs`, or `from`/`to` " +
1690
+ "quoted from a `findings` result — plus an optional `scenario` name. `scenario` defaults to " +
1691
+ "`moment-<from>-<to>` on the uptime clock when omitted, and `out` defaults to " +
1692
+ "`.porthole/traces/<scenario>.json`, the same directory `capture_system_trace` uses.\n\n" +
1693
+ "The events come from the same merged live-buffer-plus-disk view `findings` and " +
1694
+ "`what_was_happening` already use, so a save over a window quoted from a `findings` result " +
1695
+ "produces the same findings `findings` reported for it. `clippedMs` in the result says how " +
1696
+ "much of the requested window was never actually recorded — a window reaching before the " +
1697
+ "session started reports that honestly rather than silently writing a shorter trace.",
1698
+ inputSchema: {
1699
+ ...windowShape,
1700
+ scenario: z
1701
+ .string()
1702
+ .optional()
1703
+ .describe("What to call this. Defaults to moment-<from>-<to> on the uptime clock."),
1704
+ out: z
1705
+ .string()
1706
+ .optional()
1707
+ .describe("Where to write the trace. Defaults to .porthole/traces/<scenario>.json."),
1708
+ },
1709
+ annotations: { readOnlyHint: false },
1710
+ },
1711
+ async ({ sinceMs, from, to, since, scenario, out }): Promise<ToolResult> => {
1712
+ const span = await resolveWindowSince({ sinceMs, from, to, since });
1713
+ if (!span) {
1714
+ // Same shape as ask_system_trace's empty-ring refusal: there is
1715
+ // genuinely no window to save here, nothing partial to write.
1716
+ return fail(
1717
+ "No window to save: nothing is buffered live and no absolute `from`/`to` was given. " +
1718
+ "Quote a `window` from an earlier `findings` result, or pass `from`/`to` directly.",
1719
+ );
1720
+ }
1721
+
1722
+ const merged = await mergeWithDisk(span.from, span.to);
1723
+ const events = merged.events as unknown as DeviceEvent[];
1724
+ // Post-mortem is the whole point of this tool, so `device.hello` alone
1725
+ // is not enough — the app may well have exited since the moment being
1726
+ // saved. Falls back to the last confirmed process's own hello, the
1727
+ // same source `currentIdentity()` already trusts for exactly this.
1728
+ const helloLike = device.hello ?? device.lastExited?.hello ?? null;
1729
+ const hello = (helloLike as unknown as Record<string, unknown>) ?? null;
1730
+
1731
+ let resolvedScenario: string;
1732
+ let outPath: string;
1733
+ try {
1734
+ resolvedScenario = scenario === undefined ? defaultScenarioName(span.from, span.to) : validateScenario(scenario);
1735
+ outPath = out ?? defaultOutPath(resolveProjectRoot().directory, resolvedScenario);
1736
+ } catch (error) {
1737
+ if (error instanceof InvalidScenarioError) return fail(`save_moment: ${error.message}`);
1738
+ throw error;
1739
+ }
1740
+
1741
+ // GRA-185: same resolution `findings` uses — searched over the live
1742
+ // ring, not `events` (windowed to `span`), so a profile before
1743
+ // `span.from` still counts.
1744
+ const profile = resolveProfile({
1745
+ liveEvents: timeline.buffer(),
1746
+ windowTo: span.to,
1747
+ sessionProfile: device.sessions?.currentMeta()?.profile ?? null,
1748
+ hello,
1749
+ });
1750
+ const trace = buildSavedTrace({
1751
+ events,
1752
+ hello,
1753
+ window: { from: span.from, to: span.to },
1754
+ coveredFrom: merged.coveredFrom,
1755
+ coveredTo: merged.coveredTo,
1756
+ scenario: resolvedScenario,
1757
+ profile,
1758
+ });
1759
+ await writeSavedTrace(trace, outPath);
1760
+
1761
+ const findings = trace.findings.map(withFollowUp);
1762
+ return ok(
1763
+ `Saved "${resolvedScenario}" (${trace.findings.length} finding(s)) to ${outPath}.` +
1764
+ coverageNote(trace.clippedMs),
1765
+ {
1766
+ scenario: resolvedScenario,
1767
+ out: outPath,
1768
+ window: { from: span.from, to: span.to, ms: span.ms },
1769
+ clippedMs: trace.clippedMs,
1770
+ metrics: trace.metrics,
1771
+ findings,
1772
+ },
1773
+ );
1774
+ },
1775
+ );
1776
+
1777
+ server.registerTool(
1778
+ "what_was_happening",
1779
+ {
1780
+ title: "What was happening at a moment",
1781
+ description:
1782
+ "The narrative for one instant: which screen, with what arguments, what was in flight, what " +
1783
+ "the main thread was doing, and what state had just been written.\n\n" +
1784
+ "Built for the question a system trace cannot answer. Perfetto will tell you which threads " +
1785
+ "ran at 00:42.318 and for how long; it has no idea that you had just opened the cart, that " +
1786
+ "a checkout call had been open for 600ms, or that the query blocking the frame was on the " +
1787
+ "main thread. Paste the timestamp here and get the part Perfetto is missing.\n\n" +
1788
+ "Give it `at` in the device uptime clock every Porthole event carries, or `bootMs` for a " +
1789
+ "CLOCK_BOOTTIME reading taken from a Perfetto trace — the two differ by however long the " +
1790
+ "device has been in deep sleep, and the conversion uses the clock sample in force at that " +
1791
+ "moment rather than the newest one.\n\n" +
1792
+ "Durations are as they were then, not as they turned out. A call open for 600ms at the " +
1793
+ "moment asked about reports 600ms even if it ran for four seconds, because the question is " +
1794
+ "what was true then. A span that never finished says so.\n\n" +
1795
+ "Bounded by what the timeline server still holds. A moment older than the buffer cannot be " +
1796
+ "answered and will say so rather than return an empty one, which would read as 'nothing " +
1797
+ "was happening'.",
1798
+ inputSchema: {
1799
+ at: z
1800
+ .number()
1801
+ .int()
1802
+ .optional()
1803
+ .describe("The moment, in the device uptime clock. Omit if giving `bootMs`."),
1804
+ bootMs: z
1805
+ .number()
1806
+ .int()
1807
+ .optional()
1808
+ .describe(
1809
+ "The moment as CLOCK_BOOTTIME milliseconds, which is what a Perfetto trace stamps with.",
1810
+ ),
1811
+ spreadMs: z
1812
+ .number()
1813
+ .int()
1814
+ .positive()
1815
+ .max(60_000)
1816
+ .optional()
1817
+ .describe("How far either side to look for context. Default 2000."),
1818
+ },
1819
+ annotations: { readOnlyHint: true },
1820
+ },
1821
+ async ({ at, bootMs, spreadMs }): Promise<ToolResult> => {
1822
+ const events = timeline.buffer();
1823
+ if (events.length === 0) {
1824
+ // GRA-154, absorbed into GRA-157 as AC7: an empty ring is not the
1825
+ // same as a disconnected device — hello can have landed seconds ago
1826
+ // with nothing collected yet, and printing the "Not connected" wall
1827
+ // in that case blames the connection for a buffer that is merely
1828
+ // young. Same distinction `findings` and `porthole_status` make,
1829
+ // through the same method, so all three tell the same story about
1830
+ // an empty-but-attached device instead of each guessing separately.
1831
+ //
1832
+ // GRA-166 item 3 / GRA-163: `connected` is the loose sense (mirrors
1833
+ // findings' own empty-ring arm — handshaking counts as attached,
1834
+ // not just connected). Safe here for the same reason it is safe
1835
+ // there: an empty ring has no content to mislabel. The non-empty
1836
+ // branch below asks the strict question instead.
1837
+ const connected = isAttached(device.state);
1838
+ const pending = device.pendingMessage();
1839
+ // GRA-163 QA round 1: same fix as findings' empty-ring arm — an
1840
+ // empty ring can still have a `lastExited` behind it, and
1841
+ // `porthole_status` was already reporting that unconditionally
1842
+ // while this branch reported nothing, the same cross-tool
1843
+ // disagreement. `hasBufferedData: false`: this branch is reached
1844
+ // only when the ring is empty.
1845
+ const exitedProcess = exitedProcessField();
1846
+ // Inert in practice (see findings' identical comment above) but the
1847
+ // strict sense, correctly, not whichever local is in scope.
1848
+ const notice = exitedProcessNotice(exitedProcess, false, pending === null);
1849
+
1850
+ // GRA-53: this is AC1's exact shape — the MCP server was just
1851
+ // (re)started, so the live ring is empty by construction, but the
1852
+ // moment being asked about may still be sitting on disk from before
1853
+ // the restart. Tried before falling back to either "not connected"
1854
+ // or "nothing buffered yet", since a real answer beats both.
1855
+ if (at !== undefined) {
1856
+ const merged = await mergeWithDisk(0, at + (spreadMs ?? 2_000));
1857
+ if (merged.coveredFrom !== null && merged.coveredTo !== null && at >= merged.coveredFrom && at <= merged.coveredTo) {
1858
+ const moment = { ...momentOf(merged.events as unknown as DeviceEvent[], at, spreadMs ?? 2_000), clock: null };
1859
+ return ok(notice + describeMoment(moment), { ...moment, connected, exitedProcess });
1860
+ }
1861
+ }
1862
+
1863
+ if (pending !== null) {
1864
+ return ok(notice + pending, { moment: null, connected, exitedProcess });
1865
+ }
1866
+ return ok(
1867
+ notice +
1868
+ `Connected to ${device.hello!.packageName}, nothing buffered yet. Ask again in a moment.`,
1869
+ { moment: null, connected, exitedProcess },
1870
+ );
1871
+ }
1872
+
1873
+ // GRA-163: from here on the ring has content, so — exactly as in
1874
+ // findings — the ring's contents are only guaranteed to belong to the
1875
+ // running process once its hello has actually landed. Consulting
1876
+ // pendingMessage() here (previously unreached from this branch) is
1877
+ // what stops this tool from agreeing with findings' old bug: reporting
1878
+ // `connected: true` for a moment that was really a previous session's,
1879
+ // just because the socket happened to be handshaking again by the
1880
+ // time someone asked.
1881
+ const pending = device.pendingMessage();
1882
+ const connected = pending === null;
1883
+ const exitedProcess = exitedProcessField();
1884
+
1885
+ let moment_at = at;
1886
+ let clock: { bootMs: number; sleepMs: number; sampledAt: number } | null = null;
1887
+
1888
+ if (moment_at === undefined && bootMs !== undefined) {
1889
+ const converted = fromBootMs(events, bootMs);
1890
+ if (!converted) {
1891
+ return ok(
1892
+ "No clock sample in the buffer, so a boot-clock timestamp cannot be placed. " +
1893
+ "The app must have been running with Porthole attached for that to exist.",
1894
+ { moment: null, bootMs, connected, exitedProcess },
1895
+ );
1896
+ }
1897
+ moment_at = converted.at;
1898
+ // Keep the boot reading that was asked about, so the answer shows both
1899
+ // ends of the conversion rather than only the result.
1900
+ clock = { bootMs, sleepMs: converted.sleepMs, sampledAt: converted.sampledAt };
1901
+ }
1902
+
1903
+ if (moment_at === undefined) {
1904
+ return ok("Give either `at` or `bootMs`.", { moment: null, connected, exitedProcess });
1905
+ }
1906
+
1907
+ // The same shared sentence findings uses, so an agent reading both
1908
+ // tools about the same stale window sees the same story — not two
1909
+ // hand-written near-duplicates that can drift apart from each other.
1910
+ // `hasBufferedData: true`: this is the non-empty branch.
1911
+ const notice = exitedProcessNotice(exitedProcess, true, connected);
1912
+
1913
+ // Outside the buffer is a different answer from "nothing happened", and
1914
+ // conflating them is how an agent concludes the app was idle.
1915
+ const oldest = events[0].t;
1916
+ const newest = events[events.length - 1].t;
1917
+ if (moment_at < oldest || moment_at > newest) {
1918
+ // GRA-53: the ticket's headline scenario — the buffer rolled or the
1919
+ // process restarted since, but the moment may still be on disk.
1920
+ // `from: 0` rather than `oldest`: momentOf() needs the full nav
1921
+ // history up to `moment_at` to say which screen was current (the
1922
+ // last nav at-or-before the moment, not merely one inside the
1923
+ // spread window), so the merge has to reach back further than the
1924
+ // window actually returned.
1925
+ const merged = await mergeWithDisk(0, moment_at + (spreadMs ?? 2_000));
1926
+ if (
1927
+ merged.coveredFrom !== null &&
1928
+ merged.coveredTo !== null &&
1929
+ moment_at >= merged.coveredFrom &&
1930
+ moment_at <= merged.coveredTo
1931
+ ) {
1932
+ const moment = { ...momentOf(merged.events as unknown as DeviceEvent[], moment_at, spreadMs ?? 2_000), clock };
1933
+ return ok(notice + describeMoment(moment), { ...moment, connected, exitedProcess });
1934
+ }
1935
+ return ok(
1936
+ notice +
1937
+ `That moment is outside what is buffered (${oldest}–${newest} on the uptime clock). ` +
1938
+ "Not that nothing was happening — it is no longer held.",
1939
+ {
1940
+ moment: null,
1941
+ asked: moment_at,
1942
+ buffered: { from: oldest, to: newest },
1943
+ clock,
1944
+ connected,
1945
+ exitedProcess,
1946
+ },
1947
+ );
1948
+ }
1949
+
1950
+ const moment = { ...momentOf(events, moment_at, spreadMs ?? 2_000), clock };
1951
+ return ok(notice + describeMoment(moment), { ...moment, connected, exitedProcess });
1952
+ },
1953
+ );
1954
+
1955
+ server.registerTool(
1956
+ "recompositions",
1957
+ {
1958
+ title: "Recomposition counts",
1959
+ description:
1960
+ "How many times each instrumented composable recomposed, and which state keys were written " +
1961
+ "just before each recomposition. Use it to find the composable doing needless work and the " +
1962
+ "state that keeps invalidating it.\n\n" +
1963
+ "Two limits worth holding in mind: only call sites wrapped in PortholeScreen or " +
1964
+ "Modifier.portholeNode are counted, so an absent composable is uninstrumented rather than " +
1965
+ "idle; and triggeredBy is a temporal correlation within a ~32ms window, not a causal read " +
1966
+ "of the invalidation graph, so several states changing in one frame all get listed.\n\n" +
1967
+ "Keys like 'unnamed#3f2a1c' are state objects nobody named. In a Compose app most of them " +
1968
+ "belong to the framework — ripples, scroll offsets, focus, animation clocks — and are not " +
1969
+ "worth chasing. A key that is yours and still unnamed means its owner was never registered: " +
1970
+ "Porthole.registerViewModel for a ViewModel, collectAsNamedState for a Flow, " +
1971
+ "rememberNamedState for state a composable creates for itself.\n\n" +
1972
+ "A key carrying 'holds' is anonymous state that was found holding one of the app's own " +
1973
+ "types, so it is definitely the app's and definitely unregistered — that one is worth " +
1974
+ "chasing. Its absence proves nothing: an unregistered Int is indistinguishable from a " +
1975
+ "ripple, so most of the app's own unnamed state will not be flagged.",
1976
+ inputSchema: {
1977
+ screen: z
1978
+ .string()
1979
+ .optional()
1980
+ .describe(
1981
+ "Only nodes on this screen, matched against the enclosing PortholeScreen name.",
1982
+ ),
1983
+ limit: z
1984
+ .number()
1985
+ .int()
1986
+ .positive()
1987
+ .max(500)
1988
+ .optional()
1989
+ .describe("Busiest N nodes. Default 50; the tail is rarely what you are looking for."),
1990
+ ...windowShape,
1991
+ },
1992
+ annotations: { readOnlyHint: true },
1993
+ },
1994
+ async ({ screen, sinceMs, from, to, limit, since }): Promise<ToolResult> => {
1995
+ // GRA-55: resolved here, on the MCP side, rather than forwarding
1996
+ // sinceMs/since to the device — `since: "last"` needs the watermark,
1997
+ // which only this process holds. Falls back to the caller's own raw
1998
+ // sinceMs/from/to, unchanged, when nothing can be resolved (an empty
1999
+ // buffer, no watermark yet) — exactly today's behaviour for that case.
2000
+ const resolved = await resolveWindowSince({ sinceMs, from, to, since });
2001
+ const windowArgs = resolved ? { from: resolved.from, to: resolved.to } : { sinceMs, from, to };
2002
+ return call<{
2003
+ nodes: Array<{
2004
+ name: string;
2005
+ count: number;
2006
+ triggeredBy: Array<{ key: string; count: number }>;
2007
+ }>;
2008
+ totalNodes?: number;
2009
+ truncated?: boolean;
2010
+ unattributedWrites: Array<{ key: string; count: number }>;
2011
+ }>("recompositions", { screen, ...windowArgs, limit: limit ?? 50 }, (report) => {
2012
+ if (report.nodes.length === 0) {
2013
+ return "No instrumented composable recomposed in that window.";
2014
+ }
2015
+ const top = report.nodes[0];
2016
+ const cause = top.triggeredBy[0];
2017
+ const total = report.nodes.reduce((sum, node) => sum + node.count, 0);
2018
+ // A capped list that does not say it is capped reads as the whole
2019
+ // truth, which is how "only three composables recomposed" gets believed.
2020
+ const cut = report.truncated
2021
+ ? ` Busiest ${report.nodes.length} of ${report.totalNodes ?? report.nodes.length} nodes shown.`
2022
+ : "";
2023
+ return (
2024
+ `${total} recompositions across ${report.nodes.length} nodes. ` +
2025
+ `Worst: ${top.name} at ${top.count}` +
2026
+ (cause ? `, most often after a write to ${cause.key} (${cause.count} of them).` : ".") +
2027
+ cut
2028
+ );
2029
+ });
2030
+ },
2031
+ );
2032
+
2033
+ server.registerTool(
2034
+ "semantics_tree",
2035
+ {
2036
+ title: "Semantics tree",
2037
+ description:
2038
+ "The Compose semantics tree with a stable id per node. stableId is a structural path hash: " +
2039
+ "the same UI produces the same id across captures and across process restarts, so two " +
2040
+ "captures can be diffed. Nodes carrying a porthole node id line up with the ids in the " +
2041
+ "recompositions report.\n\n" +
2042
+ "A snapshot of what is on screen now. It says nothing about cost — a large tree is not a slow one — so do not infer performance from its shape; use `frames` for that.",
2043
+ inputSchema: {
2044
+ merged: z
2045
+ .boolean()
2046
+ .optional()
2047
+ .describe("Merged tree (what accessibility services see). Default true."),
2048
+ maxDepth: z.number().int().positive().optional().describe("Depth cap. Default 40."),
2049
+ maxNodes: z.number().int().positive().optional().describe("Node budget. Default 1500."),
2050
+ },
2051
+ annotations: { readOnlyHint: true },
2052
+ },
2053
+ async ({ merged, maxDepth, maxNodes }): Promise<ToolResult> =>
2054
+ call<{ root: unknown; error?: string }>(
2055
+ "semantics_tree",
2056
+ { merged, maxDepth, maxNodes },
2057
+ (tree) =>
2058
+ tree.error ? tree.error : tree.root ? "Captured the semantics tree." : "Empty tree.",
2059
+ ),
2060
+ );
2061
+
2062
+ server.registerTool(
2063
+ "nav_state",
2064
+ {
2065
+ title: "Navigation state",
2066
+ description:
2067
+ "The current back stack with each entry's route, arguments and lifecycle state, plus the " +
2068
+ "deep link that opened the app if there was one. Answers 'how did I get to this screen' " +
2069
+ "and 'what arguments is it actually holding', which is usually where the bug is.\n\n" +
2070
+ 'Present tense only. This is the stack as it is now, not how it got that way — for the order things happened in, ask `timeline` with kinds: ["nav"].',
2071
+ inputSchema: {},
2072
+ annotations: { readOnlyHint: true },
2073
+ },
2074
+ async (): Promise<ToolResult> =>
2075
+ call<{ current?: { route?: string } | null; backStack: unknown[]; error?: string }>(
2076
+ "nav_state",
2077
+ {},
2078
+ (nav) =>
2079
+ nav.error ??
2080
+ `At ${nav.current?.route ?? "an unnamed destination"} with ${nav.backStack.length} entries on the stack.`,
2081
+ ),
2082
+ );
2083
+
2084
+ server.registerTool(
2085
+ "state",
2086
+ {
2087
+ title: "ViewModel state",
2088
+ description:
2089
+ "Current values of the state held by registered ViewModels. Each field says whether writes " +
2090
+ "to it are attributable — meaning snapshot state the recomposition report can name. A " +
2091
+ "StateFlow is never attributable on its own; collectAsNamedState is what makes the State " +
2092
+ "it produces nameable.\n\n" +
2093
+ "Only registered owners appear. An empty result means nothing was registered, not that the app holds no state, so do not read absence here as evidence about the app.",
2094
+ inputSchema: {
2095
+ viewModel: z
2096
+ .string()
2097
+ .optional()
2098
+ .describe("Registered name or class name. Omit for every registered owner."),
2099
+ },
2100
+ annotations: { readOnlyHint: true },
2101
+ },
2102
+ async ({ viewModel }): Promise<ToolResult> =>
2103
+ call<{ owners: Array<{ name: string; fields: unknown[] }> }>(
2104
+ "state",
2105
+ { viewModel },
2106
+ (dump) => {
2107
+ if (dump.owners.length === 0) {
2108
+ return 'No ViewModels registered. Call Porthole.registerViewModel("CartViewModel", vm) where you obtain it.';
2109
+ }
2110
+ return dump.owners
2111
+ .map((owner) => `${owner.name} (${owner.fields.length} fields)`)
2112
+ .join(", ");
2113
+ },
2114
+ ),
2115
+ );
2116
+
2117
+ server.registerTool(
2118
+ "inflight",
2119
+ {
2120
+ title: "In-flight work",
2121
+ description:
2122
+ "Open HTTP calls with the phase each is stuck in, database queries currently executing and " +
2123
+ "the thread running them, and enqueued or running WorkManager jobs. This is the tool for " +
2124
+ "'why is this screen still spinning'.\n\n" +
2125
+ "Also returns recentHttp: the last 25 finished calls with status, headers and — when the " +
2126
+ "app opted in via BodyCapture — request and response body previews. A body with text:null " +
2127
+ "carries an omittedReason saying why it was not captured (disabled, wrong content type, " +
2128
+ "one-shot stream); that is different from the call having had no body at all.",
2129
+ inputSchema: {},
2130
+ annotations: { readOnlyHint: true },
2131
+ },
2132
+ async (): Promise<ToolResult> =>
2133
+ call<{
2134
+ http: Array<{ method: string; url: string; phase: string; elapsedMs: number }>;
2135
+ queries: Array<{ sql: string; kind: string; elapsedMs: number; thread: string }>;
2136
+ work: Array<{ name: string; state: string }>;
2137
+ recentHttp?: Array<{
2138
+ method: string;
2139
+ url: string;
2140
+ status: number | null;
2141
+ elapsedMs: number;
2142
+ }>;
2143
+ }>("inflight", {}, (flight) => {
2144
+ const parts: string[] = [];
2145
+ if (flight.http.length) {
2146
+ const worst = flight.http[0];
2147
+ parts.push(
2148
+ `${flight.http.length} HTTP call(s), oldest ${worst.method} ${worst.url} ` +
2149
+ `in '${worst.phase}' for ${worst.elapsedMs}ms`,
2150
+ );
2151
+ }
2152
+ if (flight.queries.length) {
2153
+ const writes = flight.queries.filter((q) => q.kind === "write").length;
2154
+ parts.push(
2155
+ `${flight.queries.length} query(ies) running on ${flight.queries[0].thread}` +
2156
+ (writes ? ` (${writes} write)` : ""),
2157
+ );
2158
+ }
2159
+ if (flight.work.length) parts.push(`${flight.work.length} work job(s)`);
2160
+
2161
+ const recent = flight.recentHttp ?? [];
2162
+ const failed = recent.filter((c) => c.status !== null && c.status >= 400);
2163
+ if (recent.length) {
2164
+ parts.push(
2165
+ `${recent.length} recent call(s)` +
2166
+ (failed.length ? `, ${failed.length} with a ${failed[0].status}` : ""),
2167
+ );
2168
+ }
2169
+ return parts.length ? parts.join("; ") : "Nothing in flight.";
2170
+ }),
2171
+ );
2172
+
2173
+ server.registerTool(
2174
+ "frames",
2175
+ {
2176
+ title: "Frame timing",
2177
+ description:
2178
+ "How many frames the app dropped, and where the time went in the worst ones. This is the " +
2179
+ "outcome every other collector is a proxy for: a recomposition count only matters because " +
2180
+ "of what it does to frame time.\n\n" +
2181
+ "worstPhase names the stage that dominated a janky frame, which is what decides where to " +
2182
+ "look: layoutMeasure or draw points at composition doing too much, gpu or swapBuffers at " +
2183
+ "overdraw or an expensive shader, unknownDelay at the main thread being busy with " +
2184
+ "something that is not drawing at all. Pair a jank cluster with recompositions over the " +
2185
+ "same from/to window to see whether recomposition is the cause.\n\n" +
2186
+ "Frames with firstDraw are a window being drawn for the first time and are expected to be " +
2187
+ "slow. Needs API 24 or newer.",
2188
+ inputSchema: {
2189
+ ...windowShape,
2190
+ limit: z
2191
+ .number()
2192
+ .int()
2193
+ .positive()
2194
+ .max(200)
2195
+ .optional()
2196
+ .describe("Worst N frames. Default 20."),
2197
+ },
2198
+ annotations: { readOnlyHint: true },
2199
+ },
2200
+ async ({ sinceMs, from, to, limit, since }): Promise<ToolResult> => {
2201
+ const resolved = await resolveWindowSince({ sinceMs, from, to, since });
2202
+ const windowArgs = resolved ? { from: resolved.from, to: resolved.to } : { sinceMs, from, to };
2203
+ // GRA-185's "second, smaller thing": `frames` used to print its own
2204
+ // truncated `frameIntervalMs` with no Hz named at all ("budget 8ms"),
2205
+ // while `findings` — resolving the same profile through
2206
+ // `resolveProfile` — said "8.3ms at 120Hz" for the identical panel.
2207
+ // The prose below now goes through `describeBudget`, the same
2208
+ // function `findingsOf`'s `frames-dropped` title uses, so the two
2209
+ // cannot drift apart again. `frameIntervalMs` itself, in the payload
2210
+ // below, stays exactly as the runtime sends it — only the prose
2211
+ // changes.
2212
+ const profile = resolveProfile({
2213
+ liveEvents: timeline.buffer(),
2214
+ windowTo: resolved?.to ?? Number.POSITIVE_INFINITY,
2215
+ sessionProfile: device.sessions?.currentMeta()?.profile ?? null,
2216
+ hello: (device.hello as unknown as Record<string, unknown>) ?? null,
2217
+ });
2218
+ return call<{
2219
+ totalFrames: number;
2220
+ jankyFrames: number;
2221
+ frameIntervalMs: number;
2222
+ worst: Array<{
2223
+ totalMs: number;
2224
+ missedFrames: number;
2225
+ worstPhase: string;
2226
+ firstDraw: boolean;
2227
+ }>;
2228
+ }>("frames", { ...windowArgs, limit }, (report) => {
2229
+ if (report.totalFrames === 0) return "No frames observed yet.";
2230
+ const rate = ((report.jankyFrames / report.totalFrames) * 100).toFixed(1);
2231
+ const worst = report.worst[0];
2232
+ const byPhase: Record<string, number> = {};
2233
+ for (const frame of report.worst) {
2234
+ byPhase[frame.worstPhase] = (byPhase[frame.worstPhase] ?? 0) + 1;
2235
+ }
2236
+ const phases = Object.entries(byPhase)
2237
+ .sort((a, b) => b[1] - a[1])
2238
+ .map(([phase, n]) => `${phase} ${n}`)
2239
+ .join(", ");
2240
+ return (
2241
+ `${report.jankyFrames} of ${report.totalFrames} frames janky (${rate}%), ` +
2242
+ `budget ${describeBudget(profile)}.` +
2243
+ (worst
2244
+ ? ` Worst ${worst.totalMs}ms, ${worst.missedFrames} refresh(es) missed, mostly ` +
2245
+ `${worst.worstPhase}. Across the worst frames: ${phases}.`
2246
+ : "")
2247
+ );
2248
+ });
2249
+ },
2250
+ );
2251
+
2252
+ server.registerTool(
2253
+ "blocking",
2254
+ {
2255
+ title: "Main thread blocking",
2256
+ description:
2257
+ "What held the main thread: stalls longer than the threshold, with the stack the main " +
2258
+ "thread was in at the time, and any database query that ran on it.\n\n" +
2259
+ "Stalls are found by pinging the main looper and timing the reply, so the duration is how " +
2260
+ "long everything queued ahead of the ping took. The stack is sampled once, when the ping " +
2261
+ "goes overdue, and app frames are listed first because the top frame is usually a native " +
2262
+ "read and the line you can change is a few frames down.\n\n" +
2263
+ "Database work on the main thread is reported however fast it was: a 4ms disk read in the " +
2264
+ "frame loop is a defect that has not bitten yet. For hitches shorter than the threshold, " +
2265
+ "use `frames` instead — that measures every frame, this one catches the big stops.",
2266
+ inputSchema: {
2267
+ ...windowShape,
2268
+ limit: z
2269
+ .number()
2270
+ .int()
2271
+ .positive()
2272
+ .max(100)
2273
+ .optional()
2274
+ .describe("Worst N of each. Default 20."),
2275
+ },
2276
+ annotations: { readOnlyHint: true },
2277
+ },
2278
+ async ({ sinceMs, from, to, limit, since }): Promise<ToolResult> => {
2279
+ const resolved = await resolveWindowSince({ sinceMs, from, to, since });
2280
+ const windowArgs = resolved ? { from: resolved.from, to: resolved.to } : { sinceMs, from, to };
2281
+ return call<{
2282
+ stalls: Array<{ durationMs: number; stack: string }>;
2283
+ mainThreadQueries: Array<{ sql: string; elapsedMs: number; kind: string }>;
2284
+ stallThresholdMs: number;
2285
+ }>("blocking", { ...windowArgs, limit }, (report) => {
2286
+ const parts: string[] = [];
2287
+ if (report.stalls.length) {
2288
+ const worst = report.stalls[0];
2289
+ parts.push(
2290
+ `${report.stalls.length} stall(s) over ${report.stallThresholdMs}ms, worst ` +
2291
+ `${worst.durationMs}ms in ${worst.stack.split("\n")[0]}`,
2292
+ );
2293
+ }
2294
+ if (report.mainThreadQueries.length) {
2295
+ const worst = report.mainThreadQueries[0];
2296
+ parts.push(
2297
+ `${report.mainThreadQueries.length} database ${report.mainThreadQueries.length === 1 ? "query" : "queries"} ` +
2298
+ `on the main thread, worst ${worst.elapsedMs}ms: ${worst.sql.slice(0, 80)}`,
2299
+ );
2300
+ }
2301
+ return parts.length ? parts.join(". ") : "Nothing blocked the main thread in this window.";
2302
+ });
2303
+ },
2304
+ );
2305
+
2306
+ server.registerTool(
2307
+ "logs",
2308
+ {
2309
+ title: "App logs",
2310
+ description:
2311
+ "The app's own logcat output, captured in-process and streamed over the same socket as " +
2312
+ "everything else — no adb needed. Stack traces arrive attached to the line that started " +
2313
+ "them rather than as loose fragments.\n\n" +
2314
+ "Entries carry the same uptime clock as the timeline, so a log line can be placed against " +
2315
+ "a recomposition burst or an HTTP call. Only the app's own output is visible, and the " +
2316
+ "porthole's own tag is excluded.",
2317
+ inputSchema: {
2318
+ level: z
2319
+ .enum(["V", "D", "I", "W", "E", "F"])
2320
+ .optional()
2321
+ .describe("Minimum level. 'W' for warnings and worse, which is usually what you want."),
2322
+ tag: z.string().optional().describe("Substring match on the tag."),
2323
+ contains: z.string().optional().describe("Substring match on the message."),
2324
+ ...windowShape,
2325
+ limit: z
2326
+ .number()
2327
+ .int()
2328
+ .positive()
2329
+ .max(2000)
2330
+ .optional()
2331
+ .describe("Newest N entries. Default 200."),
2332
+ },
2333
+ annotations: { readOnlyHint: true },
2334
+ },
2335
+ async ({ level, tag, contains, sinceMs, from, to, limit, since }): Promise<ToolResult> => {
2336
+ const resolved = await resolveWindowSince({ sinceMs, from, to, since });
2337
+ const windowArgs = resolved ? { from: resolved.from, to: resolved.to } : { sinceMs, from, to };
2338
+ return call<{
2339
+ entries: Array<{ level: string; tag: string; message: string; wallTime: string }>;
2340
+ capturing: boolean;
2341
+ evicted: number;
2342
+ notes: string[];
2343
+ }>("logs", { level, tag, contains, ...windowArgs, limit }, (page) => {
2344
+ if (!page.capturing) {
2345
+ return page.notes.join(" ") || "Log capture is not running.";
2346
+ }
2347
+ if (page.entries.length === 0) {
2348
+ return page.notes.join(" ") || "No log entries matched.";
2349
+ }
2350
+ const counts: Record<string, number> = {};
2351
+ for (const entry of page.entries) counts[entry.level] = (counts[entry.level] ?? 0) + 1;
2352
+ const worst = page.entries
2353
+ .filter((entry) => entry.level === "E" || entry.level === "F")
2354
+ .at(-1);
2355
+ return (
2356
+ `${page.entries.length} entries (` +
2357
+ Object.entries(counts)
2358
+ .map(([level, count]) => `${level} ${count}`)
2359
+ .join(", ") +
2360
+ ")" +
2361
+ (worst
2362
+ ? `. Latest error: ${worst.tag}: ${worst.message.split("\n")[0].slice(0, 120)}`
2363
+ : ".")
2364
+ );
2365
+ });
2366
+ },
2367
+ );
2368
+
2369
+ server.registerTool(
2370
+ "timeline",
2371
+ {
2372
+ title: "Event timeline",
2373
+ description:
2374
+ "Raw event stream: recompositions, state writes, navigation, HTTP and database start/end. " +
2375
+ "Use it to order events relative to each other — which write came before which navigation, " +
2376
+ "what the app was doing while a call was open.",
2377
+ inputSchema: {
2378
+ ...windowShape,
2379
+ kinds: z
2380
+ .array(z.string())
2381
+ .optional()
2382
+ .describe(
2383
+ "Filter by event name: recompose, state_write, frame, nav, http_start, http_end, " +
2384
+ "db_start, db_end, log.",
2385
+ ),
2386
+ limit: z
2387
+ .number()
2388
+ .int()
2389
+ .positive()
2390
+ .max(5000)
2391
+ .optional()
2392
+ .describe("Newest N events. Default 500."),
2393
+ },
2394
+ annotations: { readOnlyHint: true },
2395
+ },
2396
+ async ({ sinceMs, from, to, since, kinds, limit }): Promise<ToolResult> => {
2397
+ try {
2398
+ // Absolute bounds first, so a window quoted from another tool selects the
2399
+ // same span here. sinceMs stays as the convenience for "recently".
2400
+ const span = await resolveWindowSince({ sinceMs, from, to, since });
2401
+
2402
+ // GRA-53: the third consumer of the same merge `findings` and
2403
+ // `what_was_happening` already use — deliberately not a third
2404
+ // mechanism. A resolved span (the ordinary case, or an explicit
2405
+ // `{from, to}` quoted from an earlier answer) goes through
2406
+ // `mergeWithDisk`, which already returns events filtered to the
2407
+ // window; the un-resolved case (no span at all — nothing to bound
2408
+ // a disk lookup by) keeps the previous behaviour of asking the
2409
+ // device's own much-smaller ring directly.
2410
+ let events: DeviceEvent[];
2411
+ if (span) {
2412
+ const merged = await mergeWithDisk(span.from, span.to);
2413
+ events = merged.events as unknown as DeviceEvent[];
2414
+ } else {
2415
+ // Prefer the local buffer: it holds more history than the device
2416
+ // ring and survives the app being restarted underneath us.
2417
+ events = timeline.buffer();
2418
+ if (events.length === 0) {
2419
+ const page = await device.request<{ events: DeviceEvent[] }>("timeline", {
2420
+ limit: limit ?? 500,
2421
+ });
2422
+ events = page.events;
2423
+ }
2424
+ }
2425
+ if (kinds?.length) {
2426
+ const wanted = new Set(kinds);
2427
+ events = events.filter((event) => wanted.has(event.event));
2428
+ }
2429
+
2430
+ const matched = events.length;
2431
+ const cap = limit ?? 500;
2432
+ events = events.slice(-cap);
2433
+ const truncated = matched > events.length;
2434
+
2435
+ const counts: Record<string, number> = {};
2436
+ for (const event of events) counts[event.event] = (counts[event.event] ?? 0) + 1;
2437
+ const covered = events.length > 1 ? events[events.length - 1].t - events[0].t : 0;
2438
+
2439
+ // Say when the answer was cut. Silent truncation is how an agent
2440
+ // concludes something did not happen when it simply fell off the end.
2441
+ const note = truncated
2442
+ ? ` ${matched} matched, newest ${events.length} returned — raise \`limit\` or narrow the window.`
2443
+ : "";
2444
+ const summary =
2445
+ events.length === 0
2446
+ ? "No events matched. Interact with the app, widen the window, or check `kinds`."
2447
+ : `${events.length} events over ${covered}ms: ` +
2448
+ Object.entries(counts)
2449
+ .map(([kind, count]) => `${kind} ${count}`)
2450
+ .join(", ") +
2451
+ "." +
2452
+ note;
2453
+ return ok(summary, {
2454
+ window: span ? { from: span.from, to: span.to, ms: span.ms } : null,
2455
+ matched,
2456
+ returned: events.length,
2457
+ truncated,
2458
+ events,
2459
+ });
2460
+ } catch (error) {
2461
+ return fail(error);
2462
+ }
2463
+ },
2464
+ );
2465
+
2466
+ server.registerTool(
2467
+ "open_timeline",
2468
+ {
2469
+ title: "Open the timeline UI",
2470
+ description:
2471
+ "Starts the local timeline UI and returns its URL. Lanes for recompositions, state writes, " +
2472
+ "navigation, network and database, on a shared time axis. Open it in a browser; it updates " +
2473
+ "live over a WebSocket.",
2474
+ inputSchema: {},
2475
+ },
2476
+ async (): Promise<ToolResult> => {
2477
+ try {
2478
+ const url = await timeline.start();
2479
+ return ok(`Timeline UI running at ${url}`, { url, events: timeline.buffer().length });
2480
+ } catch (error) {
2481
+ return fail(error);
2482
+ }
2483
+ },
2484
+ );
2485
+
2486
+ return { server, device, timeline };
2487
+ }
2488
+
2489
+ // ---------------------------------------------------------------------------
2490
+ // boot
2491
+ // ---------------------------------------------------------------------------
2492
+
2493
+ /**
2494
+ * True only when this file is the process's actual entry point (`node
2495
+ * dist/index.js`, or the `porthole-mcp` bin it is published as) — never when
2496
+ * it is merely imported, which is what every test does, and what `cli.ts`
2497
+ * now does too (it calls `bootPortholeServer()` explicitly instead of
2498
+ * relying on this guard). Importing this module must never open a socket,
2499
+ * attach to stdio, or install signal handlers; only running it as a program,
2500
+ * or explicitly asking it to boot, may.
2501
+ */
2502
+ function isMainModule(): boolean {
2503
+ if (!process.argv[1]) return false;
2504
+ return pathToFileURL(process.argv[1]).href === import.meta.url;
2505
+ }
2506
+
2507
+ /**
2508
+ * The one boot path: create the server, start the device, wire shutdown, and
2509
+ * connect stdio. `node dist/index.js` and `porthole mcp` (`cli.ts`) both call
2510
+ * this instead of each having their own copy — `porthole mcp` used to boot by
2511
+ * `import("./index.js")`ing this module for its side effect, which broke the
2512
+ * moment that side effect moved behind `isMainModule()`: `argv[1]` is
2513
+ * `cli.js` when the CLI does the importing, so the guard can never see
2514
+ * itself as the entry point and nothing started. Calling this function is
2515
+ * the boot; the `isMainModule()` block below is just the one caller that
2516
+ * also happens to be `node dist/index.js` itself.
2517
+ */
2518
+ export async function bootPortholeServer(
2519
+ options: PortholeServerOptions = {},
2520
+ ): Promise<PortholeServer> {
2521
+ const rig = createPortholeServer(options);
2522
+ const { server, device, timeline } = rig;
2523
+
2524
+ device.start();
2525
+
2526
+ // stdout belongs to the MCP transport; anything we say goes to stderr.
2527
+ device.on("state", (state: string) => process.stderr.write(`[porthole] device ${state}\n`));
2528
+
2529
+ const shutdown = () => {
2530
+ device.stop();
2531
+ timeline.stop();
2532
+ process.exit(0);
2533
+ };
2534
+ process.on("SIGINT", shutdown);
2535
+ process.on("SIGTERM", shutdown);
2536
+
2537
+ await server.connect(new StdioServerTransport());
2538
+ process.stderr.write(`[porthole] MCP server ready, device target ${HOST}:${PORT}\n`);
2539
+
2540
+ return rig;
2541
+ }
2542
+
2543
+ if (isMainModule()) {
2544
+ await bootPortholeServer();
2545
+ }