@tonyclaw/agent-inspector 2.1.9 → 2.1.11

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 (30) hide show
  1. package/.output/cli.js +14671 -397
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-Dv423tJJ.js → CompareDrawer-CcwXsZp3.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-6mRRv0F1.js → ProxyViewerContainer-DFFxBlvO.js} +17 -17
  5. package/.output/public/assets/{ReplayDialog-Bury80WZ.js → ReplayDialog-XXiwmD5o.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-Bh07p6Yj.js → RequestAnatomy-DTCh37so.js} +1 -1
  7. package/.output/public/assets/{ResponseView-C8_ZLXNd.js → ResponseView-CbFYw9xP.js} +1 -1
  8. package/.output/public/assets/{StreamingChunkSequence-BKvT1lyU.js → StreamingChunkSequence-CTLMzUxV.js} +1 -1
  9. package/.output/public/assets/_sessionId-DAwcZWfu.js +1 -0
  10. package/.output/public/assets/index-B6sDoCll.js +1 -0
  11. package/.output/public/assets/{main-D9YPHKL0.js → main-C-lua4Uq.js} +2 -2
  12. package/.output/server/{_sessionId-D2z4VD7h.mjs → _sessionId-CO0WzOBc.mjs} +2 -2
  13. package/.output/server/_ssr/{CompareDrawer-DNDkVh7T.mjs → CompareDrawer-_OnPOi0O.mjs} +2 -2
  14. package/.output/server/_ssr/{ProxyViewerContainer-CI-fjnaf.mjs → ProxyViewerContainer-Bfy-cjD3.mjs} +23 -25
  15. package/.output/server/_ssr/{ReplayDialog-Dx4um0uS.mjs → ReplayDialog-DBZyQFFR.mjs} +3 -3
  16. package/.output/server/_ssr/{RequestAnatomy-CKPBN_PB.mjs → RequestAnatomy-C7m6HJOG.mjs} +2 -2
  17. package/.output/server/_ssr/{ResponseView-3wkt24-z.mjs → ResponseView-CM2nFoqa.mjs} +2 -2
  18. package/.output/server/_ssr/{StreamingChunkSequence-CSK53g5a.mjs → StreamingChunkSequence-Dj61jLHa.mjs} +2 -2
  19. package/.output/server/_ssr/{index-1uaJ_KuG.mjs → index-BI_nCxdk.mjs} +2 -2
  20. package/.output/server/_ssr/index.mjs +2 -2
  21. package/.output/server/_ssr/{router-KEX70DxH.mjs → router-WBmANYRw.mjs} +136 -119
  22. package/.output/server/{_tanstack-start-manifest_v-VhueDt-w.mjs → _tanstack-start-manifest_v-lODJPovZ.mjs} +1 -1
  23. package/.output/server/index.mjs +60 -60
  24. package/package.json +1 -1
  25. package/src/cli.ts +89 -0
  26. package/src/components/proxy-viewer/LogEntryHeader.tsx +25 -26
  27. package/src/proxy/identityProxy.ts +193 -0
  28. package/src/proxy/socketTracker.ts +387 -27
  29. package/.output/public/assets/_sessionId-DnxfC3cB.js +0 -1
  30. package/.output/public/assets/index-B1naZUTb.js +0 -1
package/src/cli.ts CHANGED
@@ -12,11 +12,13 @@ import {
12
12
  probeHostForBindHost,
13
13
  urlForHost,
14
14
  } from "./cli/networkHints.js";
15
+ import { startIdentityProxy } from "./proxy/identityProxy.js";
15
16
 
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = dirname(__filename);
18
19
 
19
20
  const DEFAULT_PORT = 25947;
21
+ const DEFAULT_IDENTITY_PORT = 25949;
20
22
  const LOCAL_PROBE_TIMEOUT_MS = 2000;
21
23
  const BRANDED_WINDOWS_RUNTIME_EXE = "agent-inspector.exe";
22
24
  const DEFAULT_CAPTURE_MODE = "simple";
@@ -198,6 +200,34 @@ function printAccessHints(port: number, host?: string): void {
198
200
  }
199
201
  }
200
202
 
203
+ /** Try to start the identity proxy. Returns `null` (and prints a warning)
204
+ * if the listen port is already in use by another process — e.g. when
205
+ * restarting a previous instance that didn't shut down cleanly. The
206
+ * TanStack Start server keeps running either way; clients just lose the
207
+ * per-PID attribution until they fall back to direct port `port` access. */
208
+ async function tryStartIdentityProxy(
209
+ identityPort: number,
210
+ upstreamPort: number,
211
+ upstreamHost: string | undefined,
212
+ ): Promise<import("node:http").Server | null> {
213
+ try {
214
+ const server = await startIdentityProxy({
215
+ listenPort: identityPort,
216
+ upstreamHost: upstreamHost ?? "127.0.0.1",
217
+ upstreamPort,
218
+ });
219
+ return server;
220
+ } catch (err) {
221
+ const message = err instanceof Error ? err.message : String(err);
222
+ console.warn(
223
+ `[identity-proxy] Could not listen on port ${identityPort}: ${message}. ` +
224
+ `Falling back to OS-scan heuristic on port ${upstreamPort}. ` +
225
+ `Pass --identity-proxy-port <n> to choose another port, or --no-identity-proxy to disable.`,
226
+ );
227
+ return null;
228
+ }
229
+ }
230
+
201
231
  async function runStart(args: string[]): Promise<void> {
202
232
  const envPort = process.env["PORT"];
203
233
  const portDefault = envPort !== undefined ? Number(envPort) : DEFAULT_PORT;
@@ -215,6 +245,11 @@ async function runStart(args: string[]): Promise<void> {
215
245
  let providersJson: string | undefined;
216
246
  let captureMode = DEFAULT_CAPTURE_MODE;
217
247
  let captureModeWasSpecified = false;
248
+ let enableIdentityProxy = true;
249
+ let identityProxyPort =
250
+ process.env["AGENT_INSPECTOR_IDENTITY_PORT"] !== undefined
251
+ ? Number(process.env["AGENT_INSPECTOR_IDENTITY_PORT"])
252
+ : DEFAULT_IDENTITY_PORT;
218
253
 
219
254
  if (envMode !== undefined && envMode !== "") {
220
255
  const parsedMode = parseCaptureMode(envMode);
@@ -312,6 +347,21 @@ async function runStart(args: string[]): Promise<void> {
312
347
  i++;
313
348
  break;
314
349
  }
350
+ case "--no-identity-proxy":
351
+ enableIdentityProxy = false;
352
+ break;
353
+ case "--identity-proxy-port": {
354
+ const value = args[i + 1];
355
+ const parsed = Number(value);
356
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
357
+ console.error(`Invalid identity-proxy port: ${String(value)}.`);
358
+ process.exitCode = 1;
359
+ return;
360
+ }
361
+ identityProxyPort = parsed;
362
+ i++;
363
+ break;
364
+ }
315
365
  default:
316
366
  break;
317
367
  }
@@ -425,6 +475,7 @@ async function runStart(args: string[]): Promise<void> {
425
475
 
426
476
  if (forceRestart) {
427
477
  killProcessOnPort(port);
478
+ if (enableIdentityProxy) killProcessOnPort(identityProxyPort);
428
479
  }
429
480
 
430
481
  console.log(`Server running at ${url}`);
@@ -495,11 +546,34 @@ async function runStart(args: string[]): Promise<void> {
495
546
  windowsHide: background,
496
547
  });
497
548
 
549
+ // Identity proxy — a native Node HTTP server that sits in front of
550
+ // TanStack Start and looks up each client's PID via the OS connection
551
+ // table, injecting the result as `X-Agent-Inspector-Client-*` headers.
552
+ // This lets AI tools (which can't easily set those headers themselves)
553
+ // still get accurate session attribution. Started only after TanStack
554
+ // Start is healthy, so the upstream is ready by the time clients connect.
555
+ const identityProxy = enableIdentityProxy
556
+ ? await tryStartIdentityProxy(identityProxyPort, port, host)
557
+ : null;
558
+
498
559
  if (background) {
499
560
  serverProcess.unref();
500
561
  if (await waitForInspectorHealthy(port, 5000, host)) {
501
562
  console.log(`agent-inspector background server is ready at ${url}`);
563
+ if (identityProxy !== null) {
564
+ console.log(`Identity proxy ready on port ${identityProxyPort} (PID-attributing proxy).`);
565
+ }
502
566
  printAccessHints(port, host);
567
+ if (identityProxy !== null) {
568
+ console.log(
569
+ ``,
570
+ `Route AI coding tools through the identity proxy for accurate PID attribution:`,
571
+ ` Claude Code: ANTHROPIC_BASE_URL=http://localhost:${identityProxyPort}/proxy claude`,
572
+ ` OpenCode: LLM_BASE_URL=http://localhost:${identityProxyPort}/proxy opencode`,
573
+ ` MiMo Code: OPENAI_BASE_URL=http://localhost:${identityProxyPort}/proxy mimo`,
574
+ ` (Direct ${port} still works; it falls back to OS-scan heuristic.)`,
575
+ );
576
+ }
503
577
  return;
504
578
  }
505
579
  console.error(`agent-inspector background server did not become ready at ${url}.`);
@@ -508,4 +582,19 @@ async function runStart(args: string[]): Promise<void> {
508
582
  }
509
583
 
510
584
  process.exitCode = await waitForProcessExit(serverProcess);
585
+
586
+ // Foreground path: print the smart-port hint now that the proxy is up,
587
+ // then keep the proxy alive until the server exits.
588
+ if (identityProxy !== null) {
589
+ console.log(
590
+ ``,
591
+ `Identity proxy is listening on port ${identityProxyPort}:`,
592
+ ` Route AI coding tools through the identity proxy for accurate PID attribution:`,
593
+ ` Claude Code: ANTHROPIC_BASE_URL=http://localhost:${identityProxyPort}/proxy claude`,
594
+ ` OpenCode: LLM_BASE_URL=http://localhost:${identityProxyPort}/proxy opencode`,
595
+ ` MiMo Code: OPENAI_BASE_URL=http://localhost:${identityProxyPort}/proxy mimo`,
596
+ ` Direct port ${port} still works (falls back to OS-scan heuristic).`,
597
+ );
598
+ await new Promise<void>((resolve) => identityProxy.close(() => resolve()));
599
+ }
511
600
  }
@@ -243,32 +243,31 @@ export const LogEntryHeader = memo(function ({
243
243
  </Tooltip>
244
244
  )}
245
245
 
246
- {/* Elapsed time */}
247
- {log.elapsedMs !== null && (
248
- <Tooltip>
249
- <TooltipTrigger asChild>
250
- <span
251
- className={cn(
252
- "flex items-center gap-1 text-xs shrink-0",
253
- isSlowResponse ? "text-amber-400" : "text-muted-foreground",
254
- )}
255
- >
256
- <Clock className="size-3" />
257
- <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
258
- {isSlowResponse && <AlertTriangle className="size-3" aria-label="Slow response" />}
259
- </span>
260
- </TooltipTrigger>
261
- <TooltipContent>
262
- {isSlowResponse
263
- ? `Slow response: ${formatElapsed(log.elapsedMs)} exceeds ${formatElapsed(
264
- slowResponseThresholdSeconds * 1000,
265
- )}`
266
- : log.streaming
267
- ? "Total streaming response time"
268
- : "Elapsed response time"}
269
- </TooltipContent>
270
- </Tooltip>
271
- )}
246
+ {/* Elapsed time. Tooltip is only shown when this log exceeded the slow-
247
+ response threshold for normal-speed logs the elapsed value is
248
+ self-explanatory, so we skip the tooltip wrapper to keep the row
249
+ quiet. */}
250
+ {log.elapsedMs !== null &&
251
+ (isSlowResponse ? (
252
+ <Tooltip>
253
+ <TooltipTrigger asChild>
254
+ <span className="flex items-center gap-1 text-xs text-amber-400 shrink-0">
255
+ <Clock className="size-3" />
256
+ <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
257
+ <AlertTriangle className="size-3" aria-label="Slow response" />
258
+ </span>
259
+ </TooltipTrigger>
260
+ <TooltipContent>
261
+ Slow response: {formatElapsed(log.elapsedMs)} exceeds{" "}
262
+ {formatElapsed(slowResponseThresholdSeconds * 1000)}
263
+ </TooltipContent>
264
+ </Tooltip>
265
+ ) : (
266
+ <span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
267
+ <Clock className="size-3" />
268
+ <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
269
+ </span>
270
+ ))}
272
271
 
273
272
  {/* Streaming timing */}
274
273
  {log.streaming && firstChunkLabel !== null && (
@@ -0,0 +1,193 @@
1
+ import http from "node:http";
2
+ import { Buffer } from "node:buffer";
3
+ import { lookupClientByPort } from "./socketTracker";
4
+
5
+ const IDENTITY_HEADERS = {
6
+ pid: "x-agent-inspector-client-pid",
7
+ port: "x-agent-inspector-client-port",
8
+ cwd: "x-agent-inspector-client-cwd",
9
+ projectFolder: "x-agent-inspector-client-project-folder",
10
+ } as const;
11
+
12
+ const REQUEST_HOP_BY_HOP = new Set([
13
+ "host",
14
+ "connection",
15
+ "keep-alive",
16
+ "proxy-authenticate",
17
+ "proxy-authorization",
18
+ "te",
19
+ "trailers",
20
+ "transfer-encoding",
21
+ "upgrade",
22
+ "content-length",
23
+ ]);
24
+
25
+ const RESPONSE_HOP_BY_HOP = new Set([
26
+ "connection",
27
+ "keep-alive",
28
+ "proxy-authenticate",
29
+ "proxy-authorization",
30
+ "te",
31
+ "trailers",
32
+ "transfer-encoding",
33
+ "upgrade",
34
+ ]);
35
+
36
+ export type IdentityProxyOptions = {
37
+ /** Port to listen on (default 25949). Clients point at this. */
38
+ listenPort: number;
39
+ /** Host the upstream TanStack Start server listens on. */
40
+ upstreamHost: string;
41
+ /** Port the upstream TanStack Start server listens on (default 25947). */
42
+ upstreamPort: number;
43
+ };
44
+
45
+ /**
46
+ * Start a tiny native Node HTTP server that:
47
+ * 1. Reads the client connection's source port (`req.socket.remotePort`).
48
+ * 2. Resolves that port to a PID via the OS connection table
49
+ * (`Get-NetTCPConnection -LocalPort <port>` on Windows,
50
+ * `ss -tanp state established` on Linux/macOS).
51
+ * 3. Resolves the PID's cwd / project folder.
52
+ * 4. Injects everything as `X-Agent-Inspector-Client-*` headers.
53
+ * 5. Reverse-proxies the request to the upstream inspector (TanStack
54
+ * Start, which already reads those headers via `getClientInfo`).
55
+ *
56
+ * Why this exists: TanStack Start strips the underlying TCP socket from
57
+ * the fetch-API `Request` before it reaches the proxy handler, so the
58
+ * handler cannot read `socket.remotePort`. Sitting in front of TanStack
59
+ * Start as a native Node server restores the ability to look up the
60
+ * client PID at the socket level — no wrapper script, no client-side
61
+ * changes, no header configuration by the AI tool author.
62
+ *
63
+ * The proxy degrades gracefully when the OS lookup fails (missing
64
+ * permissions, port not found): the request is still forwarded, just
65
+ * without identity headers, so it falls back to the existing
66
+ * best-effort scan inside TanStack Start.
67
+ */
68
+ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
69
+ return new Promise((resolve, reject) => {
70
+ const server = http.createServer();
71
+ // Eagerly resolve client identity on `connection` — at that point the
72
+ // socket is fresh and the OS connection table still lists it as
73
+ // ESTABLISHED. By the time `request` fires (let alone after we finish
74
+ // reading the body) HTTP/1.0 clients have already closed their side
75
+ // and the row is in TIME_WAIT with PID 0.
76
+ server.on("connection", (socket: import("node:net").Socket) => {
77
+ const remotePort = socket.remotePort;
78
+ if (remotePort !== undefined && remotePort > 0) {
79
+ void lookupClientByPort(remotePort).catch(() => null);
80
+ }
81
+ });
82
+ server.on("request", (req, res) => {
83
+ void handleRequest(req, res, options).catch((err: unknown) => {
84
+ const message = err instanceof Error ? err.message : String(err);
85
+ if (!res.headersSent) {
86
+ res.writeHead(502, { "content-type": "text/plain" });
87
+ }
88
+ res.end(`Identity proxy error: ${message}`);
89
+ });
90
+ });
91
+ server.once("error", reject);
92
+ server.listen(options.listenPort, () => {
93
+ server.removeListener("error", reject);
94
+ resolve(server);
95
+ });
96
+ });
97
+ }
98
+
99
+ async function handleRequest(
100
+ req: http.IncomingMessage,
101
+ res: http.ServerResponse,
102
+ options: IdentityProxyOptions,
103
+ ): Promise<void> {
104
+ // The connection-time lookup already populated the per-port cache; this
105
+ // call now hits the cache and returns the result synchronously.
106
+ const remotePort = req.socket.remotePort;
107
+ let identity: Awaited<ReturnType<typeof lookupClientByPort>> | null = null;
108
+ if (remotePort !== undefined && remotePort > 0) {
109
+ try {
110
+ identity = await lookupClientByPort(remotePort);
111
+ } catch {
112
+ identity = null;
113
+ }
114
+ }
115
+
116
+ // Buffer the request body — TanStack Start needs the raw bytes intact
117
+ // for SSE/streaming endpoints, so we can't decode-and-re-encode.
118
+ const chunks: Buffer[] = [];
119
+ for await (const chunk of req) {
120
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : toBuffer(chunk));
121
+ }
122
+ const body = Buffer.concat(chunks);
123
+
124
+ const upstreamHeaders: Record<string, string | string[]> = {};
125
+ for (const [name, value] of Object.entries(req.headers)) {
126
+ if (REQUEST_HOP_BY_HOP.has(name.toLowerCase())) continue;
127
+ if (value === undefined) continue;
128
+ upstreamHeaders[name] = value;
129
+ }
130
+ if (identity !== null && identity.pid !== null) {
131
+ upstreamHeaders[IDENTITY_HEADERS.pid] = String(identity.pid);
132
+ if (identity.port !== null) {
133
+ upstreamHeaders[IDENTITY_HEADERS.port] = String(identity.port);
134
+ }
135
+ if (identity.cwd !== null) {
136
+ upstreamHeaders[IDENTITY_HEADERS.cwd] = identity.cwd;
137
+ }
138
+ if (identity.projectFolder !== null) {
139
+ upstreamHeaders[IDENTITY_HEADERS.projectFolder] = identity.projectFolder;
140
+ }
141
+ }
142
+
143
+ process.stderr.write(
144
+ `[identity-proxy] req ${req.method} ${req.url} remotePort=${remotePort} pid=${identity?.pid ?? "?"}\n`,
145
+ );
146
+ process.stderr.write(
147
+ `[identity-proxy] forwarding headers: pid=${upstreamHeaders[IDENTITY_HEADERS.pid] ?? "?"} cwd=${upstreamHeaders[IDENTITY_HEADERS.cwd] ?? "?"}\n`,
148
+ );
149
+
150
+ const proxyReq = http.request(
151
+ {
152
+ hostname: options.upstreamHost,
153
+ port: options.upstreamPort,
154
+ path: req.url ?? "/",
155
+ method: req.method,
156
+ headers: upstreamHeaders,
157
+ },
158
+ (proxyRes) => {
159
+ const outHeaders: Record<string, string | string[]> = {};
160
+ for (const [name, value] of Object.entries(proxyRes.headers)) {
161
+ if (RESPONSE_HOP_BY_HOP.has(name.toLowerCase())) continue;
162
+ if (value === undefined) continue;
163
+ outHeaders[name] = value;
164
+ }
165
+ res.writeHead(proxyRes.statusCode ?? 502, outHeaders);
166
+ proxyRes.pipe(res);
167
+ },
168
+ );
169
+ proxyReq.on("error", (err) => {
170
+ if (!res.headersSent) {
171
+ res.writeHead(502, { "content-type": "text/plain" });
172
+ }
173
+ res.end(`Upstream error: ${err.message}`);
174
+ });
175
+ if (body.length > 0) proxyReq.write(body);
176
+ proxyReq.end();
177
+ }
178
+
179
+ // `Readable` chunks in Node are typed as `any` (they can be Buffer | string
180
+ // depending on the encoding setting). We narrow them via runtime checks.
181
+ function toBuffer(chunk: unknown): Buffer {
182
+ if (Buffer.isBuffer(chunk)) return chunk;
183
+ if (typeof chunk === "string") return Buffer.from(chunk);
184
+ // `for await` over a Node Readable always yields Buffer | string; this
185
+ // branch is only reachable if a custom stream returns something else
186
+ // (Uint8Array / ArrayBuffer / etc.). The Buffer constructor accepts an
187
+ // ArrayBuffer directly; we attempt that first, then fall back to a
188
+ // best-effort empty buffer so we never throw on an unexpected chunk.
189
+ if (chunk instanceof ArrayBuffer) return Buffer.from(chunk);
190
+ if (chunk instanceof Uint8Array)
191
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
192
+ return Buffer.alloc(0);
193
+ }