@tonyclaw/agent-inspector 2.1.9 → 2.1.10

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 (26) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-Dv423tJJ.js → CompareDrawer-BaGKK2R7.js} +1 -1
  3. package/.output/public/assets/{ProxyViewerContainer-6mRRv0F1.js → ProxyViewerContainer-Cxwizw8H.js} +4 -4
  4. package/.output/public/assets/{ReplayDialog-Bury80WZ.js → ReplayDialog-Io8Ufsif.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-Bh07p6Yj.js → RequestAnatomy-CiZE_fKZ.js} +1 -1
  6. package/.output/public/assets/{ResponseView-C8_ZLXNd.js → ResponseView-TJwxn-lh.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-BKvT1lyU.js → StreamingChunkSequence-D-9ysFSG.js} +1 -1
  8. package/.output/public/assets/_sessionId-3JCekYV3.js +1 -0
  9. package/.output/public/assets/index-B3EOwfg7.js +1 -0
  10. package/.output/public/assets/{main-D9YPHKL0.js → main-JvXpk_ov.js} +2 -2
  11. package/.output/server/{_sessionId-D2z4VD7h.mjs → _sessionId-DSZ76cjr.mjs} +2 -2
  12. package/.output/server/_ssr/{CompareDrawer-DNDkVh7T.mjs → CompareDrawer-BcUJ7lNL.mjs} +2 -2
  13. package/.output/server/_ssr/{ProxyViewerContainer-CI-fjnaf.mjs → ProxyViewerContainer-DEtSh7OB.mjs} +6 -6
  14. package/.output/server/_ssr/{ReplayDialog-Dx4um0uS.mjs → ReplayDialog-DwSynSXP.mjs} +3 -3
  15. package/.output/server/_ssr/{RequestAnatomy-CKPBN_PB.mjs → RequestAnatomy-C6LGadAw.mjs} +2 -2
  16. package/.output/server/_ssr/{ResponseView-3wkt24-z.mjs → ResponseView-Dr27DXbx.mjs} +2 -2
  17. package/.output/server/_ssr/{StreamingChunkSequence-CSK53g5a.mjs → StreamingChunkSequence-CDTaUWjj.mjs} +2 -2
  18. package/.output/server/_ssr/{index-1uaJ_KuG.mjs → index-D803rHfz.mjs} +2 -2
  19. package/.output/server/_ssr/index.mjs +2 -2
  20. package/.output/server/_ssr/{router-KEX70DxH.mjs → router-D7dDibPa.mjs} +136 -119
  21. package/.output/server/{_tanstack-start-manifest_v-VhueDt-w.mjs → _tanstack-start-manifest_v-P9lKyaOf.mjs} +1 -1
  22. package/.output/server/index.mjs +58 -58
  23. package/package.json +1 -1
  24. package/src/proxy/socketTracker.ts +265 -27
  25. package/.output/public/assets/_sessionId-DnxfC3cB.js +0 -1
  26. package/.output/public/assets/index-B1naZUTb.js +0 -1
@@ -31,6 +31,43 @@ const SocketSchema = z
31
31
  // Deduplicate concurrent lookups for the same port
32
32
  const inflight = new Map<number, Promise<ClientInfo>>();
33
33
 
34
+ // --- Recent-client scan cache -------------------------------------------
35
+ // `getClientInfo` no longer needs a per-request client port; it scans all
36
+ // current connections to our listen port and returns the freshest. We cache
37
+ // the result for a short window so a burst of requests from one client
38
+ // converges on a single subprocess call.
39
+ const RECENT_CLIENT_TTL_MS = 1000;
40
+ let recentInflight: Promise<ClientInfo> | null = null;
41
+ let recentCache: ClientInfo | null = null;
42
+ let recentCacheExpiresAt = 0;
43
+
44
+ /** The port the proxy is listening on. Falls back to the dev default. */
45
+ function getProxyPort(): number {
46
+ // The proxy server exposes its chosen port via the `PROXY_PORT` env var.
47
+ // Default to the well-known dev port so the scan still works in dev mode
48
+ // (where `proxy.ts` reads from `getConfig()` instead of an env var).
49
+ const raw = process.env["PROXY_PORT"];
50
+ if (raw !== undefined) {
51
+ const parsed = parseInt(raw, 10);
52
+ if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535) return parsed;
53
+ }
54
+ return 25947;
55
+ }
56
+
57
+ function getRecentClientFromCache(): ClientInfo | null {
58
+ if (recentCache === null) return null;
59
+ if (Date.now() >= recentCacheExpiresAt) {
60
+ recentCache = null;
61
+ return null;
62
+ }
63
+ return recentCache;
64
+ }
65
+
66
+ function setRecentClientCache(info: ClientInfo): void {
67
+ recentCache = info;
68
+ recentCacheExpiresAt = Date.now() + RECENT_CLIENT_TTL_MS;
69
+ }
70
+
34
71
  function evictCacheIfNeeded(): void {
35
72
  while (cache.size > MAX_CACHE_SIZE) {
36
73
  const oldestKey = cache.keys().next().value;
@@ -54,17 +91,62 @@ function setCache(port: number, info: ClientInfo): void {
54
91
  }
55
92
 
56
93
  /**
57
- * Get the remote port from a Request's underlying socket.
58
- * Works with Bun, Node.js, and Nitro's event.node.req.
94
+ * Best-effort remote-port extraction across runtimes. Tries, in order:
95
+ * 1. TanStack Start / Nitro: `request.context.nitro.node.req.socket` (h3
96
+ * wraps the underlying Node request, where the socket lives).
97
+ * 2. Bun-style: `request.socket` accessor.
98
+ * 3. Direct `socket` / `connection` properties on the request (legacy Node
99
+ * IncomingMessage-style wrapping).
100
+ * Returns `null` when nothing matches — the caller will then leave `pid`
101
+ * (and downstream `clientPid`) as `null`.
59
102
  */
60
103
  export function extractRemotePort(request: Request): number | null {
104
+ const candidateSockets: unknown[] = [];
105
+
106
+ // 1) Nitro / h3 path. TanStack Start attaches the h3 event under
107
+ // `request.context.nitro`, and the underlying Node request lives at
108
+ // `event.node.req`. The socket may be on `req.socket` or
109
+ // `req.connection` depending on the Node version / TLS config.
110
+ const requestAsUnknown: unknown = request;
111
+ const ctx = pick(requestAsUnknown, "context");
112
+ const nitro = pick(ctx, "nitro");
113
+ const node = pick(nitro, "node");
114
+ const innerReq = pick(node, "req");
115
+ const sock = pick(innerReq, "socket") ?? pick(innerReq, "connection");
116
+ if (sock !== undefined) candidateSockets.push(sock);
117
+
118
+ // 2) Bun-style direct socket accessor on the Request.
61
119
  const descriptor = Object.getOwnPropertyDescriptor(request, "socket");
62
- if (descriptor === undefined || descriptor.get !== undefined) return null;
63
- const parsed = SocketSchema.safeParse(descriptor.value);
64
- if (!parsed.success) return null;
65
- const remotePort = parsed.data.remotePort;
66
- if (remotePort === undefined || remotePort === null) return null;
67
- return remotePort;
120
+ if (descriptor !== undefined && descriptor.get === undefined) {
121
+ candidateSockets.push(descriptor.value);
122
+ }
123
+
124
+ // 3) Raw socket/connection properties on the request itself.
125
+ const directSocket = pick(requestAsUnknown, "socket");
126
+ if (directSocket !== undefined) candidateSockets.push(directSocket);
127
+ const directConnection = pick(requestAsUnknown, "connection");
128
+ if (directConnection !== undefined) candidateSockets.push(directConnection);
129
+
130
+ for (const candidate of candidateSockets) {
131
+ const parsed = SocketSchema.safeParse(candidate);
132
+ if (!parsed.success) continue;
133
+ const remotePort = parsed.data.remotePort;
134
+ if (remotePort === undefined || remotePort === null) continue;
135
+ return remotePort;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function isObject(value: unknown): value is Record<string, unknown> {
141
+ return value !== undefined && value !== null && typeof value === "object";
142
+ }
143
+
144
+ /** Read a property from an `unknown` without triggering lint rules that
145
+ * forbid type assertions. Returns `undefined` for non-object inputs or
146
+ * when the property is missing. */
147
+ function pick(value: unknown, key: string): unknown {
148
+ if (!isObject(value)) return undefined;
149
+ return value[key];
68
150
  }
69
151
 
70
152
  /**
@@ -226,37 +308,193 @@ async function lookupProcessInfo(
226
308
  }
227
309
 
228
310
  /**
229
- * Get client info (PID, CWD, project folder) from a request's source port.
230
- * Deduplicates concurrent lookups for the same port and caches results.
311
+ * Scan the OS connection table for the freshest client→proxy connection and
312
+ * return the matching PID plus its cwd/projectFolder.
231
313
  */
232
- export async function getClientInfo(request: Request): Promise<ClientInfo> {
233
- const port = extractRemotePort(request);
234
- if (port === null) {
314
+ async function lookupRecentClient(): Promise<ClientInfo> {
315
+ const port = getProxyPort();
316
+ const platform = process.platform;
317
+ try {
318
+ if (platform === "win32") {
319
+ // Get-NetTCPConnection gives us CreationTime on the client-side
320
+ // (-RemotePort) entries, which we sort descending to pick the freshest.
321
+ const psScript = [
322
+ `$rows = Get-NetTCPConnection -RemotePort ${port} -State Established -ErrorAction SilentlyContinue`,
323
+ `if ($rows) {`,
324
+ ` $rows = $rows | Sort-Object CreationTime -Descending`,
325
+ ` $top = $rows | Select-Object -First 1`,
326
+ ` if ($top) { Write-Output ("$($top.OwningProcess)|$($top.LocalPort)") }`,
327
+ `}`,
328
+ ].join("; ");
329
+ const { stdout } = await execFileAsync(
330
+ "powershell.exe",
331
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", psScript],
332
+ { windowsHide: true, timeout: 3000, maxBuffer: 16 * 1024 },
333
+ );
334
+ const trimmed = stdout.trim();
335
+ if (trimmed === "") return { port: null, pid: null, cwd: null, projectFolder: null };
336
+ const [pidStr, portStr] = trimmed.split("|");
337
+ const pid = parseInt(pidStr ?? "", 10);
338
+ const clientPort = parseInt(portStr ?? "", 10);
339
+ if (!Number.isInteger(pid) || pid <= 0) {
340
+ return { port: null, pid: null, cwd: null, projectFolder: null };
341
+ }
342
+ const { cwd, projectFolder } = await lookupProcessInfo(pid);
343
+ return {
344
+ port: Number.isInteger(clientPort) && clientPort > 0 ? clientPort : null,
345
+ pid,
346
+ cwd,
347
+ projectFolder,
348
+ };
349
+ }
350
+
351
+ // Unix: netstat with -p prints `pid/program` next to each entry. Pick
352
+ // the row matching our proxy port on the remote side (i.e. the client's
353
+ // ephemeral port + the proxy's listen port). When multiple clients are
354
+ // connected we take the highest ephemeral port — Linux/macOS assign
355
+ // ports in ascending order, so the largest one is the freshest.
356
+ const { stdout } = await execAsync(
357
+ `netstat -tanp 2>/dev/null | grep ':${port}' | grep ESTAB || ss -tanp 2>/dev/null | grep ':${port}' | grep ESTAB`,
358
+ { shell: "/bin/sh" },
359
+ );
360
+ const lines = stdout
361
+ .trim()
362
+ .split("\n")
363
+ .filter((line) => line.includes("ESTAB"));
364
+ const clientPid = pickNewestClientPidFromNetstat(lines, port);
365
+ if (clientPid === null) {
366
+ return { port: null, pid: null, cwd: null, projectFolder: null };
367
+ }
368
+ const { cwd, projectFolder } = await lookupProcessInfo(clientPid);
369
+ return { port: null, pid: clientPid, cwd, projectFolder };
370
+ } catch (err) {
371
+ logger.debug(`[socketTracker] Recent-client lookup failed for port ${port}:`, String(err));
235
372
  return { port: null, pid: null, cwd: null, projectFolder: null };
236
373
  }
374
+ }
375
+
376
+ /** Parse the output of `netstat -tanp` (or `ss -tanp`) and return the PID
377
+ * on the most recently opened client-side connection to `proxyPort`. */
378
+ function pickNewestClientPidFromNetstat(lines: string[], proxyPort: number): number | null {
379
+ const proxyToken = `:${proxyPort}`;
380
+ type Row = { pid: number; localPort: number; order: number };
381
+ const candidates: Row[] = [];
382
+ let order = 0;
383
+ for (const line of lines) {
384
+ order++;
385
+ const cols = line.trim().split(/\s+/);
386
+ if (cols.length < 7) continue;
387
+ const localAddr = cols[3] ?? "";
388
+ const remoteAddr = cols[4] ?? "";
389
+ const pidField = cols[6] ?? "";
390
+ // Client-side rows: `RemoteAddress` (5th col) is the proxy's listen port;
391
+ // `LocalAddress` (4th col) is the client's ephemeral port.
392
+ if (!remoteAddr.endsWith(proxyToken)) continue;
393
+ const pidMatch = pidField.match(/^(\d+)/);
394
+ if (pidMatch === null) continue;
395
+ const pid = parseInt(pidMatch[1] ?? "", 10);
396
+ if (!Number.isInteger(pid) || pid <= 0) continue;
397
+ const localPortMatch = localAddr.match(/:(\d+)$/);
398
+ const localPort = localPortMatch !== null ? parseInt(localPortMatch[1] ?? "", 10) : 0;
399
+ candidates.push({ pid, localPort, order });
400
+ }
401
+ if (candidates.length === 0) return null;
402
+ candidates.sort((a, b) => b.localPort - a.localPort || b.order - a.order);
403
+ return candidates[0]?.pid ?? null;
404
+ }
405
+
406
+ /** Custom request headers the client (or a wrapper script) may set so we
407
+ * can identify the originating process without scanning the OS connection
408
+ * table. Recognized names:
409
+ * - `X-Agent-Inspector-Client-Pid`
410
+ * - `X-Agent-Inspector-Client-Port`
411
+ * - `X-Agent-Inspector-Client-Cwd`
412
+ * - `X-Agent-Inspector-Client-Project-Folder`
413
+ * Clients should set these via a small launcher script that exports the
414
+ * values from `$$` and `pwd` before invoking the AI tool, e.g.:
415
+ *
416
+ * curl -H "X-Agent-Inspector-Client-Pid: $$" \
417
+ * -H "X-Agent-Inspector-Client-Cwd: $(pwd)" ...
418
+ */
419
+ const CLIENT_HEADERS = {
420
+ pid: "x-agent-inspector-client-pid",
421
+ port: "x-agent-inspector-client-port",
422
+ cwd: "x-agent-inspector-client-cwd",
423
+ projectFolder: "x-agent-inspector-client-project-folder",
424
+ } as const;
425
+
426
+ /** Try to read a positive-integer value out of a header. Returns `null` for
427
+ * missing / malformed values. */
428
+ function readIntHeader(headers: Headers, name: string): number | null {
429
+ const raw = headers.get(name);
430
+ if (raw === null || raw === "") return null;
431
+ const parsed = parseInt(raw, 10);
432
+ if (!Number.isInteger(parsed) || parsed < 0) return null;
433
+ return parsed;
434
+ }
435
+
436
+ /** Read client-supplied headers. Returns `null` when no PID header is
437
+ * present — caller falls back to OS scanning. */
438
+ function readClientHeaders(request: Request): ClientInfo | null {
439
+ const pid = readIntHeader(request.headers, CLIENT_HEADERS.pid);
440
+ if (pid === null || pid === 0) return null;
441
+ const port = readIntHeader(request.headers, CLIENT_HEADERS.port);
442
+ const cwd = request.headers.get(CLIENT_HEADERS.cwd);
443
+ const projectFolder = request.headers.get(CLIENT_HEADERS.projectFolder);
444
+ return {
445
+ pid,
446
+ port: port !== null && port > 0 ? port : null,
447
+ cwd: cwd !== null && cwd !== "" ? cwd : null,
448
+ projectFolder: projectFolder !== null && projectFolder !== "" ? projectFolder : null,
449
+ };
450
+ }
451
+
452
+ /**
453
+ * Get client info (PID, CWD, project folder) for the request that triggered
454
+ * this call. The TanStack Start fetch-API `Request` does not expose the
455
+ * underlying TCP socket (no `request.socket`, no `event.node.req`), so we
456
+ * cannot derive the per-request client port from the request itself.
457
+ *
458
+ * Strategy: enumerate the OS-level TCP connections to our proxy's listen
459
+ * port and pick the most recently established client-side connection. This
460
+ * is essentially "who is currently talking to us" — for a session with many
461
+ * rapid-fire requests the same client holds a long-lived connection, so
462
+ * consecutive lookups within the cache TTL converge on the same PID.
463
+ *
464
+ * Deduplicates concurrent lookups with a single in-flight promise. Results
465
+ * are cached by PID for `RECENT_CLIENT_TTL_MS` to avoid spawning a
466
+ * `Get-NetTCPConnection` / `ss` subprocess on every single request.
467
+ */
468
+ export async function getClientInfo(request: Request): Promise<ClientInfo> {
469
+ // Preferred path: explicit client identity in headers. AI coding tools
470
+ // (or the wrapper scripts that launch them) attach
471
+ // `X-Agent-Inspector-Client-Pid` (and optionally …-Cwd /
472
+ // …-Project-Folder / …-Port) so we never have to guess.
473
+ const fromHeaders = readClientHeaders(request);
474
+ if (fromHeaders !== null) return fromHeaders;
237
475
 
238
- // Check cache first
239
- const cached = getFromCache(port);
240
- if (cached) return cached;
476
+ // Fallback: scan the OS connection table for the most recently opened
477
+ // client→proxy connection. Best-effort — in environments with many
478
+ // long-lived keep-alive connections (browsers, background processes) the
479
+ // picked connection may not belong to the current request.
480
+ const cached = getRecentClientFromCache();
481
+ if (cached !== null) return cached;
241
482
 
242
- // Deduplicate concurrent lookups for the same port
243
- const existing = inflight.get(port);
483
+ const existing = recentInflight;
244
484
  if (existing) return existing;
245
485
 
246
- const promise = lookupClientInfo(port)
486
+ const promise = lookupRecentClient()
247
487
  .then((info) => {
248
- setCache(port, info);
249
- inflight.delete(port);
488
+ setRecentClientCache(info);
489
+ recentInflight = null;
250
490
  return info;
251
491
  })
252
492
  .catch((err) => {
253
- inflight.delete(port);
254
- logger.debug(`[socketTracker] Lookup failed for port ${port}:`, String(err));
255
- const fallback: ClientInfo = { port, pid: null, cwd: null, projectFolder: null };
256
- setCache(port, fallback);
257
- return fallback;
493
+ recentInflight = null;
494
+ logger.debug(`[socketTracker] Recent-client lookup failed:`, String(err));
495
+ return { port: null, pid: null, cwd: null, projectFolder: null };
258
496
  });
259
497
 
260
- inflight.set(port, promise);
498
+ recentInflight = promise;
261
499
  return promise;
262
500
  }
@@ -1 +0,0 @@
1
- import{R as s,j as e}from"./main-D9YPHKL0.js";import{P as i}from"./ProxyViewerContainer-6mRRv0F1.js";function t(){const{sessionId:o}=s.useParams();return e.jsx(i,{initialSessionId:o},o)}export{t as component};
@@ -1 +0,0 @@
1
- import{P as o}from"./ProxyViewerContainer-6mRRv0F1.js";import"./main-D9YPHKL0.js";const r=o;export{r as component};