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