@gravitylabsllc/porthole 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +123 -0
  2. package/dist/adb.js +430 -21
  3. package/dist/adb.js.map +1 -1
  4. package/dist/args.js +144 -0
  5. package/dist/args.js.map +1 -0
  6. package/dist/capture.js +139 -30
  7. package/dist/capture.js.map +1 -1
  8. package/dist/cli.js +221 -62
  9. package/dist/cli.js.map +1 -1
  10. package/dist/device.js +337 -4
  11. package/dist/device.js.map +1 -1
  12. package/dist/index.js +2030 -377
  13. package/dist/index.js.map +1 -1
  14. package/dist/moment.js +240 -0
  15. package/dist/moment.js.map +1 -0
  16. package/dist/perfetto.js +826 -0
  17. package/dist/perfetto.js.map +1 -0
  18. package/dist/report.js +68 -7
  19. package/dist/report.js.map +1 -1
  20. package/dist/save.js +252 -0
  21. package/dist/save.js.map +1 -0
  22. package/dist/sessions.js +704 -0
  23. package/dist/sessions.js.map +1 -0
  24. package/dist/system.js +169 -0
  25. package/dist/system.js.map +1 -0
  26. package/dist/systrace.js +198 -0
  27. package/dist/systrace.js.map +1 -0
  28. package/dist/timeline.js +731 -29
  29. package/dist/timeline.js.map +1 -1
  30. package/dist/trace.js +317 -27
  31. package/dist/trace.js.map +1 -1
  32. package/dist/watermark.js +220 -0
  33. package/dist/watermark.js.map +1 -0
  34. package/package.json +10 -4
  35. package/src/adb.ts +583 -0
  36. package/src/args.ts +177 -0
  37. package/src/capture.ts +292 -0
  38. package/src/cli.ts +367 -0
  39. package/src/device.ts +635 -0
  40. package/src/index.ts +2545 -0
  41. package/src/moment.ts +306 -0
  42. package/src/perfetto.ts +972 -0
  43. package/src/report.ts +285 -0
  44. package/src/save.ts +322 -0
  45. package/src/sessions.ts +894 -0
  46. package/src/system.ts +221 -0
  47. package/src/systrace.ts +258 -0
  48. package/src/timeline.ts +1036 -0
  49. package/src/trace.ts +769 -0
  50. package/src/watermark.ts +337 -0
  51. package/ui/dist/assets/index-BzqwnvoU.js +70 -0
  52. package/ui/dist/assets/index-DtnyBXCM.css +1 -0
  53. package/ui/dist/index.html +2 -2
  54. package/ui/dist/assets/index--1mlZuNZ.css +0 -1
  55. package/ui/dist/assets/index-BeVGHRFm.js +0 -68
package/src/device.ts ADDED
@@ -0,0 +1,635 @@
1
+ // Copyright 2026 Gravity Labs
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import net from "node:net";
4
+ import { EventEmitter } from "node:events";
5
+ import { SessionWriter, type SessionEvent } from "./sessions.js";
6
+
7
+ /**
8
+ * One frame per line, UTF-8. A frame carrying `id` is a response to a request
9
+ * we sent; a frame carrying `event` is the device telling us something happened.
10
+ */
11
+ export interface DeviceEvent {
12
+ event: string;
13
+ /** Device uptime in ms. Not wall clock — see hello.startedAt for the origin. */
14
+ t: number;
15
+ seq: number;
16
+ data: Record<string, unknown>;
17
+ }
18
+
19
+ interface Response {
20
+ id: number;
21
+ ok: boolean;
22
+ result?: unknown;
23
+ error?: string;
24
+ }
25
+
26
+ export interface Hello {
27
+ protocol: number;
28
+ packageName: string;
29
+ processName: string;
30
+ versionName: string | null;
31
+ device: string;
32
+ sdkInt: number;
33
+ startedAt: number;
34
+ collectors: string[];
35
+ /**
36
+ * GRA-53: the third leg of on-disk session identity, `(packageName,
37
+ * startedAt, deviceId)` — see `sessions.ts`'s module doc comment.
38
+ * Deliberately optional and deliberately not named `serial`: it is not
39
+ * adb's own device serial (that is known host-side, in the Gradle
40
+ * plugin's connection file — a different mechanism entirely, GRA-119's
41
+ * territory) and a build that has not sent one yet must degrade session
42
+ * identity, not break it. `sessions.ts`'s `UNKNOWN_DEVICE_ID` is the
43
+ * fallback every existing `hello` fixture in this test suite exercises,
44
+ * since none of them set this field.
45
+ */
46
+ deviceId?: string;
47
+ }
48
+
49
+ /**
50
+ * GRA-96: the constant this side of the socket owns, matching
51
+ * `PROTOCOL_VERSION` in `runtime/.../protocol/Protocol.kt` — the two are not
52
+ * derived from a shared source, so a wire-format change obliges updating
53
+ * both by hand (see that file's own comment on the constant it owns). This
54
+ * is a bare integer with no `major.minor` split: a match means compatible,
55
+ * anything else is a refusal, recorded in `protocolMismatch` below rather
56
+ * than thrown, so the rest of the handshake can finish and the mismatch can
57
+ * be reported as the specific, actionable message `porthole_status` needs
58
+ * (GRA-96 AC1/AC2) instead of a generic connection failure.
59
+ */
60
+ export const PROTOCOL_VERSION = 1;
61
+
62
+ /**
63
+ * GRA-157: the socket connecting and the app saying hello are two different
64
+ * events, roughly 2s apart on real hardware, and treating them as one was the
65
+ * bug. "handshaking" names the gap: the socket is up, `request()` can already
66
+ * be used (hello itself goes over it), but `hello` is still null. "connected"
67
+ * is now a promise, not just a name — see `setState()` below, which refuses
68
+ * to enter it while `hello` is null, and `connect()`'s hello handler, which is
69
+ * the only place that promise is fulfilled. Every caller that used to write
70
+ * `state === "connected" && hello` can drop the `&& hello`; every caller that
71
+ * used to write `state === "connected"` alone and silently mean "and hello
72
+ * happens to be set" was the bug, and now has three states to actually name
73
+ * what it meant.
74
+ */
75
+ export type ConnectionState = "disconnected" | "connecting" | "handshaking" | "connected";
76
+
77
+ /**
78
+ * GRA-163: the ring survives a close (timeline.ts never clears it there —
79
+ * only a new `hello` does, because the post-mortem case, "what happened
80
+ * before it died", is exactly when someone needs those events most), but
81
+ * that leaves a gap the ring itself cannot answer: whose process produced
82
+ * what is still buffered, and when did it stop. This is that answer, kept
83
+ * on `DeviceClient` because it is the thing that watches the socket close —
84
+ * set in the close handler below, read by every tool that might describe
85
+ * buffered data without a live session behind it.
86
+ *
87
+ * This is the counterpart to what a `hello` already does on the other side
88
+ * of the session boundary: a `hello` discards the previous session's ring;
89
+ * a close records the one that just ended, for whoever reads what is left
90
+ * of it afterward.
91
+ *
92
+ * **Founder's decision, 2026-09-15: label answers as belonging to the
93
+ * exited process, rather than clearing the ring or hiding them.**
94
+ * This was built as an explicitly stated assumption and carried that label
95
+ * until the founder confirmed it; the wording is updated here so nobody
96
+ * reads a settled decision as an open bet. Retaining `lastExited` (instead
97
+ * of, say, dropping it the moment the socket closes, which would make a
98
+ * stale ring silently indistinguishable from an empty one again) is what
99
+ * that decision means in code, recorded here because this is where someone
100
+ * revisiting it would start looking — a search of `index.ts` alone would not
101
+ * show that a decision was made, only its consequences. Were it ever
102
+ * reversed, the change is to stop setting this field (or to clear it, and
103
+ * the ring, on close) rather than to hunt through every caller. Five tests
104
+ * pin it and would have to change with it: `index.test.ts`'s "AC1: with a non-empty ring and the device
105
+ * disconnected, all three tools agree and none reports the dead process as
106
+ * live", "AC2/AC5: with a non-empty ring and the device handshaking again,
107
+ * no tool reports connected: true about the previous session's data", "AC3:
108
+ * on socket close the ring is kept, not cleared, and device.lastExited
109
+ * records who it belonged to", and "the ordinary case is unaffected:
110
+ * connected: true still means the ring is confirmed live, not a previous
111
+ * session's leftovers"; and `timeline.test.ts`'s "the ring is not cleared
112
+ * by a close — only by a new hello — so it still holds what an exited
113
+ * process produced".
114
+ */
115
+ export interface ExitedSession {
116
+ /** The process that produced whatever the ring may still hold. */
117
+ hello: Hello;
118
+ /** Wall-clock time (`Date.now()`) the socket actually closed. */
119
+ disconnectedAt: number;
120
+ }
121
+
122
+ /** The one sentence every tool uses for "the socket is up, hello has not landed yet" — GRA-157 AC3. */
123
+ export const HANDSHAKE_PENDING_MESSAGE =
124
+ "Connected, waiting on the app's first check-in. Ask again in a moment.";
125
+
126
+ const RECONNECT_MIN_MS = 500;
127
+ const RECONNECT_MAX_MS = 5_000;
128
+ const REQUEST_TIMEOUT_MS = 5_000;
129
+
130
+ /**
131
+ * Talks to the app over the adb-forwarded loopback port.
132
+ *
133
+ * Reconnects on its own, because the far end is an app being actively developed:
134
+ * it gets killed, reinstalled and relaunched constantly, and none of that should
135
+ * require restarting the MCP server.
136
+ */
137
+ export class DeviceClient extends EventEmitter {
138
+ private socket: net.Socket | null = null;
139
+ private buffer = "";
140
+ private nextId = 1;
141
+ private pending = new Map<
142
+ number,
143
+ { resolve: (value: unknown) => void; reject: (reason: Error) => void; timer: NodeJS.Timeout }
144
+ >();
145
+ private reconnectDelay = RECONNECT_MIN_MS;
146
+ private reconnectTimer: NodeJS.Timeout | null = null;
147
+ private closed = false;
148
+
149
+ state: ConnectionState = "disconnected";
150
+ hello: Hello | null = null;
151
+ lastError: string | null = null;
152
+ /**
153
+ * GRA-163: null until a session that actually got a `hello` has closed;
154
+ * from then on, the most recent one — overwritten on every subsequent
155
+ * close that had a `hello`, so it always names the last confirmed process,
156
+ * never a stale one from further back. See the close handler in
157
+ * `connect()` for where it is set, and index.ts's `exitedProcessField()`/
158
+ * `exitedProcessNotice()` for where it turns into what every tool
159
+ * actually reports (moved there after QA round 1: this field says whose
160
+ * process it is, but only the caller knows whether the ring it is about
161
+ * to describe is empty — see `pendingMessage()`'s own comment below).
162
+ */
163
+ lastExited: ExitedSession | null = null;
164
+ /**
165
+ * GRA-96: null when the app's `hello.protocol` matches `PROTOCOL_VERSION`,
166
+ * otherwise the sentence `porthole_status` reports verbatim — set once,
167
+ * in `connect()`'s hello handler, right where `hello` itself is set, so it
168
+ * is never stale relative to whichever `hello` is currently held. Kept
169
+ * separate from `lastError`: that field means "the socket or a request
170
+ * failed", this one means "the socket and the handshake both succeeded and
171
+ * the two sides still cannot be trusted to agree on the wire format" — a
172
+ * different fact that deserves its own name instead of overloading
173
+ * lastError's "something went wrong" with a case that is not a failure to
174
+ * connect at all.
175
+ */
176
+ protocolMismatch: string | null = null;
177
+
178
+ /**
179
+ * GRA-53 `#session-writer`: null (persistence off) unless a sessions root
180
+ * is given. `undefined`/omitted is the default on purpose — every existing
181
+ * test in `device.test.ts` constructs a `DeviceClient` with two arguments
182
+ * and auto-answers `hello`, so leaving this off must not start writing
183
+ * real files into whatever directory the test happened to run from. The
184
+ * real MCP server boot path (`index.ts`'s `createPortholeServer`) passes
185
+ * one explicitly.
186
+ */
187
+ readonly sessions: SessionWriter | null;
188
+
189
+ constructor(
190
+ private readonly host: string,
191
+ readonly port: number,
192
+ sessionsRoot?: string,
193
+ ) {
194
+ super();
195
+ this.sessions = sessionsRoot ? new SessionWriter(sessionsRoot) : null;
196
+ }
197
+
198
+ start(): void {
199
+ this.closed = false;
200
+ this.connect();
201
+ }
202
+
203
+ stop(): void {
204
+ this.closed = true;
205
+ // Clearing the timeout without nulling the field leaves scheduleReconnect()'s
206
+ // guard (`this.closed || this.reconnectTimer`) permanently true after a later
207
+ // start(): the stale, already-cleared timer looks exactly like a reconnect
208
+ // that is still scheduled, so a subsequent disconnect never retries.
209
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
210
+ this.reconnectTimer = null;
211
+ // GRA-191: close the writer here, synchronously with stop() itself,
212
+ // rather than relying solely on the "close" handler below reacting to
213
+ // socket.destroy() — that handler fires on a later tick (net sockets
214
+ // emit "close" asynchronously), which is exactly the gap that let a
215
+ // still-armed 250ms flush timer survive past a test's own teardown and
216
+ // fire against a sessions root the teardown had already removed
217
+ // (GRA-183's reproduction). close() flushes whatever is queued (which
218
+ // also cancels that timer — see SessionWriter.doFlush()'s own first
219
+ // lines) and marks the writer closed so nothing appended after this
220
+ // point queues into a writer nothing will flush again.
221
+ this.sessions?.close();
222
+ this.socket?.destroy();
223
+ this.socket = null;
224
+ this.setState("disconnected");
225
+ }
226
+
227
+ private connect(): void {
228
+ if (this.closed || this.socket) return;
229
+ this.setState("connecting");
230
+
231
+ const socket = net.createConnection({ host: this.host, port: this.port });
232
+ socket.setNoDelay(true);
233
+ socket.setEncoding("utf8");
234
+ this.socket = socket;
235
+
236
+ socket.on("connect", () => {
237
+ this.reconnectDelay = RECONNECT_MIN_MS;
238
+ this.lastError = null;
239
+ // Not "connected" yet — GRA-157. The TCP handshake finishing says
240
+ // nothing about whether the far end is a Porthole runtime, let alone
241
+ // which app; hello is what answers that, and it has not been asked
242
+ // yet at this line. Callers that need "connected" is real now get it:
243
+ // the state stays "handshaking" until the block below actually has a
244
+ // Hello in hand.
245
+ this.setState("handshaking");
246
+ // hello doubles as a liveness check and as the timeline's origin.
247
+ this.request<Hello>("hello")
248
+ .then(async (hello) => {
249
+ // Order matters: hello is set before the state change that
250
+ // announces it, so anything reacting to the "state" event (or
251
+ // reading `.hello` right after seeing state flip to "connected")
252
+ // never observes "connected" with `hello` still null. setState()
253
+ // also asserts this itself, so a future edit that reordered these
254
+ // two lines would fail loudly instead of reintroducing the race.
255
+ this.hello = hello;
256
+ // GRA-96: computed right where `hello` is set, not deferred to
257
+ // whichever tool asks later — a caller reading `protocolMismatch`
258
+ // right after the "hello" event below always sees the answer for
259
+ // the `hello` it just received, never a stale one from a previous
260
+ // connection. Refusal, not a thrown error: the socket is fine and
261
+ // the app really did answer, so the rest of the surface (findings,
262
+ // timeline, …) still works for whatever it can, and this is the
263
+ // one specific, actionable fact layered on top (GRA-96 AC1/AC2).
264
+ // Synchronous, like the assignment above — that is what lets it
265
+ // sit between `this.hello = hello` and the emit below without
266
+ // reopening GRA-191's race (see that emit's own comment).
267
+ this.protocolMismatch =
268
+ hello.protocol === PROTOCOL_VERSION
269
+ ? null
270
+ : `The app is speaking protocol ${hello.protocol}; this server understands protocol ` +
271
+ `${PROTOCOL_VERSION}. Update the app's Porthole runtime dependency to a version that ` +
272
+ `speaks protocol ${PROTOCOL_VERSION}, or pin the npm package this MCP server runs from ` +
273
+ `(in .mcp.json) to the version that matches the app.`;
274
+ // GRA-191: `this.hello = hello` above and this emit must stay
275
+ // exactly this close together, with nothing between them that can
276
+ // yield to the event loop. GRA-53 used to put
277
+ // `await this.sessions.open(hello)` right here, reasoning that
278
+ // "connected" — and the events a caller might start sending the
279
+ // instant it sees that state — should never arrive before there
280
+ // was somewhere for `append()` to put them. That reasoning was
281
+ // right about `append()` and wrong about this emit: `open()` is
282
+ // real disk I/O (mkdir/readFile/writeFile/enforceRetention), and
283
+ // awaiting it here opened a window in which `timeline.ts`'s
284
+ // "hello" listener — the thing that clears its ring for a new
285
+ // session — had not run yet, so an event arriving in that window
286
+ // got pushed onto the ring first and was wiped a moment later when
287
+ // the delayed "hello" finally fired (GRA-183's reproduction).
288
+ // Moving the emit up here removes the window instead of narrowing
289
+ // it: every "hello" listener runs to completion before this
290
+ // function's own next line ever executes, so nothing can land
291
+ // between "the ring has cleared for this session" and "the ring
292
+ // is accepting this session's events" — there is no gap for
293
+ // anything to land in. Do not put an `await` of any kind between
294
+ // the assignment above and this emit — that is the exact mistake
295
+ // GRA-191 is about. `sessions.open()` still runs, just after, and
296
+ // `SessionWriter.append()` now queues anything that arrives while
297
+ // it is still in flight rather than dropping it (see sessions.ts),
298
+ // so nothing below this line depends on `open()` having already
299
+ // resolved.
300
+ this.emit("hello", hello);
301
+ // GRA-53 `#session-writer`: opens (or resumes) the on-disk session
302
+ // for this identity. Awaited before `setState("connected")` below
303
+ // — not before the emit above any more — so "connected" still
304
+ // means what GRA-157's tests pin (a writer whose directory is
305
+ // guaranteed to exist by the time a caller sees that state)
306
+ // without that guarantee costing the emit its synchronicity.
307
+ if (this.sessions) await this.sessions.open(hello);
308
+ this.setState("connected");
309
+ })
310
+ .catch((error: Error) => {
311
+ // The socket is still open and still usable — only the hello
312
+ // round-trip failed (most likely its own 5s timeout, if the far
313
+ // end accepted the TCP connection but never speaks the protocol).
314
+ // Staying in "handshaking" rather than falling back to "connected"
315
+ // is the point of this whole change; there is deliberately no
316
+ // retry here, since request() already gives every other method
317
+ // the same 5s timeout and nothing about hello is special enough to
318
+ // loop on its own.
319
+ this.lastError = error.message;
320
+ });
321
+ });
322
+
323
+ socket.on("data", (chunk: string) => this.onData(chunk));
324
+
325
+ socket.on("error", (error: Error) => {
326
+ this.lastError = error.message;
327
+ });
328
+
329
+ socket.on("close", () => {
330
+ this.socket = null;
331
+ // GRA-53: flush promptly on disconnect rather than waiting for the
332
+ // interval timer — a killed app or a dropped socket is exactly the
333
+ // moment nothing else will prompt a flush for a while, and the whole
334
+ // point of writing to disk is that this data survives the socket
335
+ // going away. Deliberately NOT sessions.close()/finalize: a reconnect
336
+ // that gets the same hello back (open()'s idempotent-by-identity
337
+ // check) resumes appending to this same file, so the session directory
338
+ // itself is left exactly as GRA-163 leaves the ring — kept, not
339
+ // cleared, until a genuinely new hello says otherwise.
340
+ void this.sessions?.flush();
341
+ // GRA-163: captured before `hello` is cleared below, and only when
342
+ // there was one — a socket that closes mid-handshake (this.hello
343
+ // still null) never had a confirmed session to record, and recording
344
+ // one here would overwrite the real last-exited process with nothing.
345
+ if (this.hello) {
346
+ this.lastExited = { hello: this.hello, disconnectedAt: Date.now() };
347
+ }
348
+ this.hello = null;
349
+ // GRA-96: cleared with `hello`, for the same reason — a mismatch is a
350
+ // fact about the `hello` that produced it, and once that `hello` is
351
+ // gone (a new connection will get its own, possibly no longer
352
+ // mismatched) there is nothing left for this to still be true about.
353
+ this.protocolMismatch = null;
354
+ this.failPending("device disconnected");
355
+ this.setState("disconnected");
356
+ this.scheduleReconnect();
357
+ });
358
+ }
359
+
360
+ private scheduleReconnect(): void {
361
+ if (this.closed || this.reconnectTimer) return;
362
+ this.reconnectTimer = setTimeout(() => {
363
+ this.reconnectTimer = null;
364
+ this.connect();
365
+ }, this.reconnectDelay);
366
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
367
+ }
368
+
369
+ private onData(chunk: string): void {
370
+ this.buffer += chunk;
371
+ let newline = this.buffer.indexOf("\n");
372
+ while (newline >= 0) {
373
+ const line = this.buffer.slice(0, newline).trim();
374
+ this.buffer = this.buffer.slice(newline + 1);
375
+ if (line.length > 0) this.onLine(line);
376
+ newline = this.buffer.indexOf("\n");
377
+ }
378
+ }
379
+
380
+ private onLine(line: string): void {
381
+ let frame: Response | DeviceEvent;
382
+ try {
383
+ frame = JSON.parse(line);
384
+ } catch {
385
+ return;
386
+ }
387
+
388
+ if ("event" in frame) {
389
+ // GRA-53: queued, not written — SessionWriter.append() only ever
390
+ // pushes to its own in-memory array and arms a flush timer, so this
391
+ // line does not touch the filesystem and cannot be the reason a frame
392
+ // is processed late. GRA-191: append() no longer needs the session's
393
+ // directory to already exist to accept this — persistence being off
394
+ // entirely, or a writer `close()` already closed, are the only cases
395
+ // it still silently drops; an event that arrives while `open()` is
396
+ // still awaiting disk I/O (or has not even been called yet — see
397
+ // `connect()`'s "hello" handler) is queued and written once the
398
+ // directory exists.
399
+ this.sessions?.append(frame as SessionEvent);
400
+ this.emit("event", frame);
401
+ return;
402
+ }
403
+
404
+ const waiter = this.pending.get(frame.id);
405
+ if (!waiter) return;
406
+ this.pending.delete(frame.id);
407
+ clearTimeout(waiter.timer);
408
+ if (frame.ok) waiter.resolve(frame.result);
409
+ else waiter.reject(new Error(frame.error ?? "unknown device error"));
410
+ }
411
+
412
+ private failPending(reason: string): void {
413
+ for (const [, waiter] of this.pending) {
414
+ clearTimeout(waiter.timer);
415
+ waiter.reject(new Error(reason));
416
+ }
417
+ this.pending.clear();
418
+ }
419
+
420
+ private setState(state: ConnectionState): void {
421
+ // GRA-157 AC1: "connected" implies `hello` is non-null, enforced here —
422
+ // the one place `state` is actually assigned — rather than left for
423
+ // every reader to remember. If this throws, the bug is in this file
424
+ // (most likely connect()'s hello handler setting state before hello),
425
+ // never in whatever asked for the transition.
426
+ if (state === "connected" && this.hello === null) {
427
+ throw new Error(
428
+ "DeviceClient invariant violated: cannot enter state 'connected' with hello still null",
429
+ );
430
+ }
431
+ if (this.state === state) return;
432
+ this.state = state;
433
+ this.emit("state", state);
434
+ }
435
+
436
+ request<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
437
+ const socket = this.socket;
438
+ // "handshaking" is allowed here on purpose: it is the exact state hello
439
+ // itself is sent in (see connect()'s socket.on("connect") above, which
440
+ // sets "handshaking" and then calls request<Hello>("hello") next) —
441
+ // gating on "connected" alone would make sending hello reject itself.
442
+ // Only "connecting" (TCP handshake still in flight — this.socket exists
443
+ // but has not fired "connect" yet) and "disconnected" have no usable
444
+ // socket to write a frame to.
445
+ //
446
+ // GRA-162 QA: this used to spell the same condition out longhand as
447
+ // `this.state !== "handshaking" && this.state !== "connected"`, which is
448
+ // isAttached() negated (De Morgan's) but written by hand instead of
449
+ // through it. That made this a sixth silent site the AC 2 probe did not
450
+ // catch: a `!==` pair against two literals still compiles unchanged when
451
+ // the union grows, and a future state would fall through to "not
452
+ // attached" and reject every request() call with the not-connected
453
+ // message even while the socket was genuinely live. Routing through
454
+ // isAttached() puts this choke point behind the same never-guarded
455
+ // switch as the rest, so a new state fails `tsc` here too instead of
456
+ // silently rejecting live traffic.
457
+ if (!socket || !isAttached(this.state)) {
458
+ return Promise.reject(new Error(this.notConnectedMessage()));
459
+ }
460
+
461
+ const id = this.nextId++;
462
+ // Strip undefined so optional tool arguments do not become JSON nulls that
463
+ // the Kotlin side would have to special-case.
464
+ const cleaned: Record<string, unknown> = {};
465
+ for (const [key, value] of Object.entries(params)) {
466
+ if (value !== undefined) cleaned[key] = value;
467
+ }
468
+
469
+ return new Promise<T>((resolve, reject) => {
470
+ const timer = setTimeout(() => {
471
+ this.pending.delete(id);
472
+ reject(new Error(`device did not answer '${method}' within ${REQUEST_TIMEOUT_MS}ms`));
473
+ }, REQUEST_TIMEOUT_MS);
474
+
475
+ this.pending.set(id, {
476
+ resolve: resolve as (value: unknown) => void,
477
+ reject,
478
+ timer,
479
+ });
480
+ socket.write(JSON.stringify({ id, method, params: cleaned }) + "\n");
481
+ });
482
+ }
483
+
484
+ /**
485
+ * The disconnected/handshaking half of what every tool says about the
486
+ * connection, written once instead of separately by porthole_status,
487
+ * findings, and whichever tool asks next (GRA-157 AC3). Returns null when
488
+ * `state` is "connected", since `hello` is guaranteed non-null there (see
489
+ * setState() above) and what to say about a live connection differs by
490
+ * caller — porthole_status names the collectors, findings talks about the
491
+ * buffer — so that half stays with each tool.
492
+ *
493
+ * The switch is exhaustive on purpose, with a compiled-in `never` check
494
+ * instead of a `default` that quietly falls through: a fifth
495
+ * ConnectionState added later without a case here fails `tsc`, in this one
496
+ * place, rather than silently being treated as either "connected" or the
497
+ * wall. Everywhere else asks this method instead of re-deriving the answer
498
+ * from `state` and `hello` by hand, which is the actual fix GRA-157 is
499
+ * about. (GRA-162: this used to say it was "deliberately the only place in
500
+ * the package with that check" — it no longer is. QA counted eight sites
501
+ * outside this file that read `device.state === "…"` directly, which
502
+ * `tsc` does not flag when a state is added because `===` against a string
503
+ * literal just evaluates false for anything new. `isAttached()`,
504
+ * `isConnected()` and `isHandshaking()` below give those call sites the
505
+ * same guarantee this switch has always had, instead of leaving them to
506
+ * reinvent it inconsistently or not at all.)
507
+ */
508
+ /**
509
+ * GRA-163 QA round 1: this used to append an "exited process" sentence of
510
+ * its own (`withExitedSessionNote()`, now removed) whenever `lastExited`
511
+ * was set. That sentence unconditionally said "whatever is still buffered
512
+ * is from X" — true on the branch that has a non-empty ring, false on the
513
+ * branch that does not (the empty-ring-plus-`lastExited` case: a process
514
+ * reconnects, clears the ring on its own `hello`, then dies before
515
+ * emitting anything), because this method has no way to know which one it
516
+ * is being asked from — `pendingMessage()` only ever sees `this.state`
517
+ * and `this.lastExited`, never `timeline.buffer().length`. Two more
518
+ * faults rode along with that one: `porthole_status` calls
519
+ * `exitedProcessField()` unconditionally while `findings`/
520
+ * `what_was_happening` only called it from their non-empty-ring branch,
521
+ * so the same state produced a payload with `exitedProcess` on one tool
522
+ * and without it on another — and the prose was folded into this string
523
+ * while the structured field lived in index.ts, so the two could disagree
524
+ * even about a single tool's own single answer.
525
+ *
526
+ * The fix moves all of it to index.ts's `exitedProcessNotice()`, called
527
+ * from every branch of every tool that might describe ring content,
528
+ * because index.ts is the one place that actually knows whether the ring
529
+ * it is about to describe is empty. This method goes back to answering
530
+ * exactly what its name says: how to reconnect, or that the handshake is
531
+ * still pending. Nothing else.
532
+ */
533
+ pendingMessage(): string | null {
534
+ switch (this.state) {
535
+ case "disconnected":
536
+ case "connecting":
537
+ return this.notConnectedMessage();
538
+ case "handshaking":
539
+ return HANDSHAKE_PENDING_MESSAGE;
540
+ case "connected":
541
+ return null;
542
+ default: {
543
+ const exhaustive: never = this.state;
544
+ throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
545
+ }
546
+ }
547
+ }
548
+
549
+ notConnectedMessage(): string {
550
+ return [
551
+ `Not connected to the app on ${this.host}:${this.port}.`,
552
+ this.lastError ? `Last socket error: ${this.lastError}.` : null,
553
+ "Check, in order:",
554
+ " 1. the debug build is running on the device (the porthole starts with the process)",
555
+ " 2. the adb bridge is up: 'adb forward tcp:PORT tcp:PORT', which",
556
+ " 'porthole ui' and './gradlew portholeConnect' both do for you",
557
+ ` 3. nothing else on this machine is holding ${this.port}`,
558
+ ]
559
+ .filter(Boolean)
560
+ .join("\n");
561
+ }
562
+ }
563
+
564
+ // --- GRA-162: exhaustive readers of a bare ConnectionState ------------------
565
+ //
566
+ // Free functions, not methods, because every call site below holds a
567
+ // ConnectionState value (`device.state`, or one carried on an event/message)
568
+ // rather than a DeviceClient to ask. Each is a switch with the same
569
+ // compiled-in `never` guard as pendingMessage() above: adding a fifth
570
+ // ConnectionState without extending a case list here fails `tsc` at that
571
+ // list, not silently at nothing. Three functions rather than one because the
572
+ // call sites genuinely want three different questions answered, and a single
573
+ // helper returning a wider type would just move the "did I handle the new
574
+ // case" judgement call to every caller instead of to the compiler here.
575
+
576
+ /**
577
+ * The "loose" sense `findings`, `porthole_status` and `what_was_happening`
578
+ * use: the socket is up, whether or not `hello` has landed. True for
579
+ * "handshaking" and "connected"; false for "connecting" and "disconnected".
580
+ */
581
+ export function isAttached(state: ConnectionState): boolean {
582
+ switch (state) {
583
+ case "handshaking":
584
+ case "connected":
585
+ return true;
586
+ case "connecting":
587
+ case "disconnected":
588
+ return false;
589
+ default: {
590
+ const exhaustive: never = state;
591
+ throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * The "strict" sense: `hello` has actually landed. True only for
598
+ * "connected" — see setState()'s invariant above, which makes that the only
599
+ * state in which `hello` is guaranteed non-null.
600
+ */
601
+ export function isConnected(state: ConnectionState): boolean {
602
+ switch (state) {
603
+ case "connected":
604
+ return true;
605
+ case "connecting":
606
+ case "handshaking":
607
+ case "disconnected":
608
+ return false;
609
+ default: {
610
+ const exhaustive: never = state;
611
+ throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * True only while the handshake is in flight: the socket is up, `hello` has
618
+ * not landed. Named separately from isAttached()/isConnected() because
619
+ * several call sites want to say something specific about the handshake
620
+ * window rather than lump it in with either "attached" or "not yet".
621
+ */
622
+ export function isHandshaking(state: ConnectionState): boolean {
623
+ switch (state) {
624
+ case "handshaking":
625
+ return true;
626
+ case "connecting":
627
+ case "connected":
628
+ case "disconnected":
629
+ return false;
630
+ default: {
631
+ const exhaustive: never = state;
632
+ throw new Error(`DeviceClient: unhandled ConnectionState '${exhaustive as string}'`);
633
+ }
634
+ }
635
+ }