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