@tonyclaw/agent-inspector 2.1.10 → 2.1.12

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 (31) hide show
  1. package/.output/cli.js +14676 -399
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-BaGKK2R7.js → CompareDrawer-XTdSOto9.js} +1 -1
  4. package/.output/public/assets/{ProxyViewerContainer-Cxwizw8H.js → ProxyViewerContainer-Cc944N8i.js} +19 -19
  5. package/.output/public/assets/{ReplayDialog-Io8Ufsif.js → ReplayDialog-Cd1VsfRe.js} +1 -1
  6. package/.output/public/assets/{RequestAnatomy-CiZE_fKZ.js → RequestAnatomy-QxSpbmsj.js} +1 -1
  7. package/.output/public/assets/{ResponseView-TJwxn-lh.js → ResponseView-8GivXCzp.js} +1 -1
  8. package/.output/public/assets/{StreamingChunkSequence-D-9ysFSG.js → StreamingChunkSequence-CwNGqJ5n.js} +1 -1
  9. package/.output/public/assets/_sessionId-D89uDTQs.js +1 -0
  10. package/.output/public/assets/index-BsFdgXac.js +1 -0
  11. package/.output/public/assets/{main-JvXpk_ov.js → main-B_EcGyf-.js} +2 -2
  12. package/.output/server/{_sessionId-DSZ76cjr.mjs → _sessionId-CmO9read.mjs} +2 -2
  13. package/.output/server/_ssr/{CompareDrawer-BcUJ7lNL.mjs → CompareDrawer-D9wf870o.mjs} +2 -2
  14. package/.output/server/_ssr/{ProxyViewerContainer-DEtSh7OB.mjs → ProxyViewerContainer-BDLJSawP.mjs} +49 -30
  15. package/.output/server/_ssr/{ReplayDialog-DwSynSXP.mjs → ReplayDialog-B2dS4kmP.mjs} +3 -3
  16. package/.output/server/_ssr/{RequestAnatomy-C6LGadAw.mjs → RequestAnatomy-k49Peshi.mjs} +2 -2
  17. package/.output/server/_ssr/{ResponseView-Dr27DXbx.mjs → ResponseView-Bk3B1suu.mjs} +2 -2
  18. package/.output/server/_ssr/{StreamingChunkSequence-CDTaUWjj.mjs → StreamingChunkSequence-D6MDGCgh.mjs} +2 -2
  19. package/.output/server/_ssr/{index-D803rHfz.mjs → index-CC8KdlTb.mjs} +2 -2
  20. package/.output/server/_ssr/index.mjs +2 -2
  21. package/.output/server/_ssr/{router-D7dDibPa.mjs → router-BEmMnVa7.mjs} +213 -28
  22. package/.output/server/{_tanstack-start-manifest_v-P9lKyaOf.mjs → _tanstack-start-manifest_v-Bf_4hG4w.mjs} +1 -1
  23. package/.output/server/index.mjs +55 -55
  24. package/package.json +1 -1
  25. package/src/cli.ts +107 -2
  26. package/src/components/proxy-viewer/ConversationGroup.tsx +22 -2
  27. package/src/components/proxy-viewer/LogEntryHeader.tsx +35 -29
  28. package/src/proxy/identityProxy.ts +192 -0
  29. package/src/proxy/socketTracker.ts +222 -24
  30. package/.output/public/assets/_sessionId-3JCekYV3.js +0 -1
  31. package/.output/public/assets/index-B3EOwfg7.js +0 -1
@@ -38,100 +38,100 @@ const assets = {
38
38
  "/assets/alibaba-TTwafVwX.svg": {
39
39
  "type": "image/svg+xml",
40
40
  "etag": '"171b-6dyV5K8QjiaY35sN9qNprh9zDIs"',
41
- "mtime": "2026-07-04T02:02:10.927Z",
41
+ "mtime": "2026-07-04T05:26:41.755Z",
42
42
  "size": 5915,
43
43
  "path": "../public/assets/alibaba-TTwafVwX.svg"
44
44
  },
45
- "/assets/CompareDrawer-BaGKK2R7.js": {
45
+ "/assets/CompareDrawer-XTdSOto9.js": {
46
46
  "type": "text/javascript; charset=utf-8",
47
- "etag": '"49e8-G1wNmDGmbVxNPpz1a8oaOgtZzos"',
48
- "mtime": "2026-07-04T02:02:10.929Z",
47
+ "etag": '"49e8-bu14iyddw7i6hLggEbQwIlgQT/g"',
48
+ "mtime": "2026-07-04T05:26:41.755Z",
49
49
  "size": 18920,
50
- "path": "../public/assets/CompareDrawer-BaGKK2R7.js"
51
- },
52
- "/assets/index-B3EOwfg7.js": {
53
- "type": "text/javascript; charset=utf-8",
54
- "etag": '"74-+kptPbQ2kpjXzSI1SU6SHbX2+SQ"',
55
- "mtime": "2026-07-04T02:02:10.929Z",
56
- "size": 116,
57
- "path": "../public/assets/index-B3EOwfg7.js"
50
+ "path": "../public/assets/CompareDrawer-XTdSOto9.js"
58
51
  },
59
52
  "/assets/index-CmtfjQPv.css": {
60
53
  "type": "text/css; charset=utf-8",
61
54
  "etag": '"1e968-15dLAIs72Zhi4Qe/CRWBTVZ7MtI"',
62
- "mtime": "2026-07-04T02:02:10.929Z",
55
+ "mtime": "2026-07-04T05:26:41.755Z",
63
56
  "size": 125288,
64
57
  "path": "../public/assets/index-CmtfjQPv.css"
65
58
  },
66
- "/assets/main-JvXpk_ov.js": {
59
+ "/assets/main-B_EcGyf-.js": {
67
60
  "type": "text/javascript; charset=utf-8",
68
- "etag": '"512fd-ayrpztHB+N7SGRB3bs6EZN4hnBo"',
69
- "mtime": "2026-07-04T02:02:10.929Z",
61
+ "etag": '"512fd-vx5h/KMQz3ePll6B2LBWhS5rz1E"',
62
+ "mtime": "2026-07-04T05:26:41.755Z",
70
63
  "size": 332541,
71
- "path": "../public/assets/main-JvXpk_ov.js"
64
+ "path": "../public/assets/main-B_EcGyf-.js"
72
65
  },
73
66
  "/assets/minimax-BPMzvuL-.jpeg": {
74
67
  "type": "image/jpeg",
75
68
  "etag": '"1b06-IwivU89ko5UTMUM1/t7hn4sQK9A"',
76
- "mtime": "2026-07-04T02:02:10.929Z",
69
+ "mtime": "2026-07-04T05:26:41.755Z",
77
70
  "size": 6918,
78
71
  "path": "../public/assets/minimax-BPMzvuL-.jpeg"
79
72
  },
80
- "/assets/RequestAnatomy-CiZE_fKZ.js": {
73
+ "/assets/ReplayDialog-Cd1VsfRe.js": {
81
74
  "type": "text/javascript; charset=utf-8",
82
- "etag": '"43f2-pcT1lJeQNTmnbmOFKpaANfuLxXQ"',
83
- "mtime": "2026-07-04T02:02:10.929Z",
84
- "size": 17394,
85
- "path": "../public/assets/RequestAnatomy-CiZE_fKZ.js"
75
+ "etag": '"318c-spkeYXrEB4wG/uX3hf+85eWDNJk"',
76
+ "mtime": "2026-07-04T05:26:41.755Z",
77
+ "size": 12684,
78
+ "path": "../public/assets/ReplayDialog-Cd1VsfRe.js"
86
79
  },
87
- "/assets/ReplayDialog-Io8Ufsif.js": {
80
+ "/assets/index-BsFdgXac.js": {
88
81
  "type": "text/javascript; charset=utf-8",
89
- "etag": '"318c-OhkrsLfrsml3ZquW9kjlH6TiWK0"',
90
- "mtime": "2026-07-04T02:02:10.929Z",
91
- "size": 12684,
92
- "path": "../public/assets/ReplayDialog-Io8Ufsif.js"
82
+ "etag": '"74-db3//XcXc3MHuPF0eOLgXsErJTY"',
83
+ "mtime": "2026-07-04T05:26:41.755Z",
84
+ "size": 116,
85
+ "path": "../public/assets/index-BsFdgXac.js"
93
86
  },
94
- "/assets/ResponseView-TJwxn-lh.js": {
87
+ "/assets/ResponseView-8GivXCzp.js": {
95
88
  "type": "text/javascript; charset=utf-8",
96
- "etag": '"30d2-zO82t52tyuafUQNMKhsmem+8W9c"',
97
- "mtime": "2026-07-04T02:02:10.929Z",
89
+ "etag": '"30d2-iJTTpaNykHiLvmnmUvp+FrIs6ks"',
90
+ "mtime": "2026-07-04T05:26:41.755Z",
98
91
  "size": 12498,
99
- "path": "../public/assets/ResponseView-TJwxn-lh.js"
92
+ "path": "../public/assets/ResponseView-8GivXCzp.js"
93
+ },
94
+ "/assets/RequestAnatomy-QxSpbmsj.js": {
95
+ "type": "text/javascript; charset=utf-8",
96
+ "etag": '"43f2-xjujMxYvXtaY+PgPnEev1/QeFAI"',
97
+ "mtime": "2026-07-04T05:26:41.755Z",
98
+ "size": 17394,
99
+ "path": "../public/assets/RequestAnatomy-QxSpbmsj.js"
100
+ },
101
+ "/assets/qwen-CONDcHqt.png": {
102
+ "type": "image/png",
103
+ "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
104
+ "mtime": "2026-07-04T05:26:41.755Z",
105
+ "size": 357059,
106
+ "path": "../public/assets/qwen-CONDcHqt.png"
100
107
  },
101
108
  "/assets/zhipuai-BPNAnxo-.svg": {
102
109
  "type": "image/svg+xml",
103
110
  "etag": '"2bf8-hNaLCTi89nOFCsIIfWpP/jrfo0s"',
104
- "mtime": "2026-07-04T02:02:10.929Z",
111
+ "mtime": "2026-07-04T05:26:41.755Z",
105
112
  "size": 11256,
106
113
  "path": "../public/assets/zhipuai-BPNAnxo-.svg"
107
114
  },
108
- "/assets/StreamingChunkSequence-D-9ysFSG.js": {
115
+ "/assets/_sessionId-D89uDTQs.js": {
109
116
  "type": "text/javascript; charset=utf-8",
110
- "etag": '"d5c-Siu/PgtKfi5RvFy2DV5/y0lzSDU"',
111
- "mtime": "2026-07-04T02:02:10.930Z",
112
- "size": 3420,
113
- "path": "../public/assets/StreamingChunkSequence-D-9ysFSG.js"
114
- },
115
- "/assets/_sessionId-3JCekYV3.js": {
116
- "type": "text/javascript; charset=utf-8",
117
- "etag": '"d2-Ck1cytNneSGYTerMobA/d7kU7E4"',
118
- "mtime": "2026-07-04T02:02:10.929Z",
117
+ "etag": '"d2-63rh9pFgM3iAAYD/jdB7M0OPa1w"',
118
+ "mtime": "2026-07-04T05:26:41.755Z",
119
119
  "size": 210,
120
- "path": "../public/assets/_sessionId-3JCekYV3.js"
120
+ "path": "../public/assets/_sessionId-D89uDTQs.js"
121
121
  },
122
- "/assets/qwen-CONDcHqt.png": {
123
- "type": "image/png",
124
- "etag": '"572c3-cdJAPaHdOvFCGzuaQjagdgOu6XE"',
125
- "mtime": "2026-07-04T02:02:10.929Z",
126
- "size": 357059,
127
- "path": "../public/assets/qwen-CONDcHqt.png"
122
+ "/assets/StreamingChunkSequence-CwNGqJ5n.js": {
123
+ "type": "text/javascript; charset=utf-8",
124
+ "etag": '"d5c-0RMlG+zdOOYhcXL94BP73yEZKCM"',
125
+ "mtime": "2026-07-04T05:26:41.755Z",
126
+ "size": 3420,
127
+ "path": "../public/assets/StreamingChunkSequence-CwNGqJ5n.js"
128
128
  },
129
- "/assets/ProxyViewerContainer-Cxwizw8H.js": {
129
+ "/assets/ProxyViewerContainer-Cc944N8i.js": {
130
130
  "type": "text/javascript; charset=utf-8",
131
- "etag": '"c1bf2-b7zp7bO7iKSX7NYjIYX/gBZE8b4"',
132
- "mtime": "2026-07-04T02:02:10.929Z",
133
- "size": 793586,
134
- "path": "../public/assets/ProxyViewerContainer-Cxwizw8H.js"
131
+ "etag": '"c1d99-n90ipQQM8DOvJtMKVbz7nJrU49s"',
132
+ "mtime": "2026-07-04T05:26:41.755Z",
133
+ "size": 794009,
134
+ "path": "../public/assets/ProxyViewerContainer-Cc944N8i.js"
135
135
  }
136
136
  };
137
137
  function readAsset(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonyclaw/agent-inspector",
3
- "version": "2.1.10",
3
+ "version": "2.1.12",
4
4
  "type": "module",
5
5
  "description": "Agent observability and knowledge capture layer for AI coding tools.",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -12,11 +12,19 @@ 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
 
20
+ // External-facing port stays at 25947 so existing tooling / AI clients
21
+ // (Claude Code, OpenCode, MiMo Code, etc.) keep working with the
22
+ // unchanged `ANTHROPIC_BASE_URL=http://localhost:25947/proxy`. The
23
+ // identity-injecting proxy listens on that port; the TanStack Start
24
+ // upstream is moved to a private port (25949 by default) and reverse-
25
+ // proxied through.
19
26
  const DEFAULT_PORT = 25947;
27
+ const DEFAULT_UPSTREAM_PORT = 25949;
20
28
  const LOCAL_PROBE_TIMEOUT_MS = 2000;
21
29
  const BRANDED_WINDOWS_RUNTIME_EXE = "agent-inspector.exe";
22
30
  const DEFAULT_CAPTURE_MODE = "simple";
@@ -198,6 +206,34 @@ function printAccessHints(port: number, host?: string): void {
198
206
  }
199
207
  }
200
208
 
209
+ /** Try to start the identity proxy. Returns `null` (and prints a warning)
210
+ * if the listen port is already in use by another process — e.g. when
211
+ * restarting a previous instance that didn't shut down cleanly. The
212
+ * TanStack Start server keeps running either way; clients just lose the
213
+ * per-PID attribution until they fall back to direct port `port` access. */
214
+ async function tryStartIdentityProxy(
215
+ identityPort: number,
216
+ upstreamPort: number,
217
+ upstreamHost: string | undefined,
218
+ ): Promise<import("node:http").Server | null> {
219
+ try {
220
+ const server = await startIdentityProxy({
221
+ listenPort: identityPort,
222
+ upstreamHost: upstreamHost ?? "127.0.0.1",
223
+ upstreamPort,
224
+ });
225
+ return server;
226
+ } catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err);
228
+ console.warn(
229
+ `[identity-proxy] Could not listen on port ${identityPort}: ${message}. ` +
230
+ `Falling back to OS-scan heuristic on port ${upstreamPort}. ` +
231
+ `Pass --identity-proxy-port <n> to choose another port, or --no-identity-proxy to disable.`,
232
+ );
233
+ return null;
234
+ }
235
+ }
236
+
201
237
  async function runStart(args: string[]): Promise<void> {
202
238
  const envPort = process.env["PORT"];
203
239
  const portDefault = envPort !== undefined ? Number(envPort) : DEFAULT_PORT;
@@ -215,6 +251,11 @@ async function runStart(args: string[]): Promise<void> {
215
251
  let providersJson: string | undefined;
216
252
  let captureMode = DEFAULT_CAPTURE_MODE;
217
253
  let captureModeWasSpecified = false;
254
+ let enableIdentityProxy = true;
255
+ let identityProxyPort =
256
+ process.env["AGENT_INSPECTOR_PORT"] !== undefined
257
+ ? Number(process.env["AGENT_INSPECTOR_PORT"])
258
+ : DEFAULT_PORT;
218
259
 
219
260
  if (envMode !== undefined && envMode !== "") {
220
261
  const parsedMode = parseCaptureMode(envMode);
@@ -312,6 +353,21 @@ async function runStart(args: string[]): Promise<void> {
312
353
  i++;
313
354
  break;
314
355
  }
356
+ case "--no-identity-proxy":
357
+ enableIdentityProxy = false;
358
+ break;
359
+ case "--identity-proxy-port": {
360
+ const value = args[i + 1];
361
+ const parsed = Number(value);
362
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
363
+ console.error(`Invalid identity-proxy port: ${String(value)}.`);
364
+ process.exitCode = 1;
365
+ return;
366
+ }
367
+ identityProxyPort = parsed;
368
+ i++;
369
+ break;
370
+ }
315
371
  default:
316
372
  break;
317
373
  }
@@ -395,7 +451,12 @@ async function runStart(args: string[]): Promise<void> {
395
451
  }
396
452
  }
397
453
 
398
- process.env["PORT"] = String(port);
454
+ // TanStack Start listens on the private upstream port; the identity
455
+ // proxy owns the public-facing `port`. When the identity proxy is
456
+ // disabled (or not yet running) we fall back to TanStack listening on
457
+ // the public port directly.
458
+ const upstreamPort = enableIdentityProxy ? DEFAULT_UPSTREAM_PORT : port;
459
+ process.env["PORT"] = String(upstreamPort);
399
460
 
400
461
  const url = urlForHost(port, host);
401
462
 
@@ -425,6 +486,10 @@ async function runStart(args: string[]): Promise<void> {
425
486
 
426
487
  if (forceRestart) {
427
488
  killProcessOnPort(port);
489
+ if (enableIdentityProxy) {
490
+ killProcessOnPort(identityProxyPort);
491
+ killProcessOnPort(upstreamPort);
492
+ }
428
493
  }
429
494
 
430
495
  console.log(`Server running at ${url}`);
@@ -495,11 +560,36 @@ async function runStart(args: string[]): Promise<void> {
495
560
  windowsHide: background,
496
561
  });
497
562
 
563
+ // Identity proxy — a native Node HTTP server that sits in front of
564
+ // TanStack Start and looks up each client's PID via the OS connection
565
+ // table, injecting the result as `X-Agent-Inspector-Client-*` headers.
566
+ // This lets AI tools (which can't easily set those headers themselves)
567
+ // still get accurate session attribution. Started only after TanStack
568
+ // Start is healthy, so the upstream is ready by the time clients connect.
569
+ const identityProxy = enableIdentityProxy
570
+ ? await tryStartIdentityProxy(identityProxyPort, upstreamPort, host)
571
+ : null;
572
+
498
573
  if (background) {
499
574
  serverProcess.unref();
500
- if (await waitForInspectorHealthy(port, 5000, host)) {
575
+ // Probe the upstream TanStack port directly the identity proxy is
576
+ // not started yet so the public port is not yet accepting traffic.
577
+ if (await waitForInspectorHealthy(upstreamPort, 5000, host)) {
501
578
  console.log(`agent-inspector background server is ready at ${url}`);
579
+ if (identityProxy !== null) {
580
+ console.log(`Identity proxy ready on port ${identityProxyPort} (PID-attributing proxy).`);
581
+ }
502
582
  printAccessHints(port, host);
583
+ if (identityProxy !== null) {
584
+ console.log(
585
+ ``,
586
+ `Route AI coding tools through the identity proxy for accurate PID attribution:`,
587
+ ` Claude Code: ANTHROPIC_BASE_URL=http://localhost:${identityProxyPort}/proxy claude`,
588
+ ` OpenCode: LLM_BASE_URL=http://localhost:${identityProxyPort}/proxy opencode`,
589
+ ` MiMo Code: OPENAI_BASE_URL=http://localhost:${identityProxyPort}/proxy mimo`,
590
+ ` (Direct ${port} still works; it falls back to OS-scan heuristic.)`,
591
+ );
592
+ }
503
593
  return;
504
594
  }
505
595
  console.error(`agent-inspector background server did not become ready at ${url}.`);
@@ -508,4 +598,19 @@ async function runStart(args: string[]): Promise<void> {
508
598
  }
509
599
 
510
600
  process.exitCode = await waitForProcessExit(serverProcess);
601
+
602
+ // Foreground path: print the smart-port hint now that the proxy is up,
603
+ // then keep the proxy alive until the server exits.
604
+ if (identityProxy !== null) {
605
+ console.log(
606
+ ``,
607
+ `Identity proxy is listening on port ${identityProxyPort}:`,
608
+ ` Route AI coding tools through the identity proxy for accurate PID attribution:`,
609
+ ` Claude Code: ANTHROPIC_BASE_URL=http://localhost:${identityProxyPort}/proxy claude`,
610
+ ` OpenCode: LLM_BASE_URL=http://localhost:${identityProxyPort}/proxy opencode`,
611
+ ` MiMo Code: OPENAI_BASE_URL=http://localhost:${identityProxyPort}/proxy mimo`,
612
+ ` Direct port ${port} still works (falls back to OS-scan heuristic).`,
613
+ );
614
+ await new Promise<void>((resolve) => identityProxy.close(() => resolve()));
615
+ }
511
616
  }
@@ -77,6 +77,24 @@ function collectProviders(logs: CapturedLog[]): {
77
77
  }));
78
78
  }
79
79
 
80
+ export function getFirstClientPid(logs: readonly CapturedLog[]): number | null {
81
+ for (const log of logs) {
82
+ const pid = log.clientPid;
83
+ if (pid !== null && pid !== undefined) return pid;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ export function getFirstClientProjectFolder(logs: readonly CapturedLog[]): string | null {
89
+ for (const log of logs) {
90
+ const projectFolder = log.clientProjectFolder;
91
+ if (projectFolder !== null && projectFolder !== undefined && projectFolder !== "") {
92
+ return projectFolder;
93
+ }
94
+ }
95
+ return null;
96
+ }
97
+
80
98
  export const ConversationGroup = memo(function ({
81
99
  group,
82
100
  viewMode = "simple",
@@ -110,6 +128,8 @@ export const ConversationGroup = memo(function ({
110
128
  const endTime = group.logs[group.logs.length - 1]?.timestamp ?? new Date().toISOString();
111
129
  const isLoading = group.logs.some((log) => log.responseStatus === null);
112
130
  const showTraceRollupMetrics = standalone && !hasPinnedSessionContext;
131
+ const clientPid = useMemo(() => getFirstClientPid(group.logs), [group.logs]);
132
+ const clientProjectFolder = useMemo(() => getFirstClientProjectFolder(group.logs), [group.logs]);
113
133
 
114
134
  // Pre-compute stop reasons for each log — used by turnIndices
115
135
  const turnGroups = useMemo(() => buildTurnGroups(group.logs), [group.logs]);
@@ -144,8 +164,8 @@ export const ConversationGroup = memo(function ({
144
164
  providers={providers}
145
165
  isLoading={isLoading}
146
166
  userAgent={group.logs[0]?.userAgent ?? null}
147
- clientPid={group.logs[0]?.clientPid ?? null}
148
- clientProjectFolder={group.logs[0]?.clientProjectFolder ?? null}
167
+ clientPid={clientPid}
168
+ clientProjectFolder={clientProjectFolder}
149
169
  timeDisplayFormat={timeDisplayFormat}
150
170
  onClear={() => onClearGroup(group.logs.map((l) => l.id))}
151
171
  />
@@ -162,6 +162,13 @@ export const LogEntryHeader = memo(function ({
162
162
  const tokenRateLabel =
163
163
  tokensPerSecond === null || tokensPerSecond <= 0 ? null : formatTokenRate(tokensPerSecond);
164
164
  const warningCount = log.warnings?.length ?? 0;
165
+ const hasClientPid = log.clientPid !== null && log.clientPid !== undefined;
166
+ const hasClientProjectFolder =
167
+ log.clientProjectFolder !== null &&
168
+ log.clientProjectFolder !== undefined &&
169
+ log.clientProjectFolder !== "";
170
+ const hasClientCwd =
171
+ log.clientCwd !== null && log.clientCwd !== undefined && log.clientCwd !== "";
165
172
 
166
173
  return (
167
174
  <TooltipProvider>
@@ -243,32 +250,31 @@ export const LogEntryHeader = memo(function ({
243
250
  </Tooltip>
244
251
  )}
245
252
 
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
- )}
253
+ {/* Elapsed time. Tooltip is only shown when this log exceeded the slow-
254
+ response threshold for normal-speed logs the elapsed value is
255
+ self-explanatory, so we skip the tooltip wrapper to keep the row
256
+ quiet. */}
257
+ {log.elapsedMs !== null &&
258
+ (isSlowResponse ? (
259
+ <Tooltip>
260
+ <TooltipTrigger asChild>
261
+ <span className="flex items-center gap-1 text-xs text-amber-400 shrink-0">
262
+ <Clock className="size-3" />
263
+ <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
264
+ <AlertTriangle className="size-3" aria-label="Slow response" />
265
+ </span>
266
+ </TooltipTrigger>
267
+ <TooltipContent>
268
+ Slow response: {formatElapsed(log.elapsedMs)} exceeds{" "}
269
+ {formatElapsed(slowResponseThresholdSeconds * 1000)}
270
+ </TooltipContent>
271
+ </Tooltip>
272
+ ) : (
273
+ <span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
274
+ <Clock className="size-3" />
275
+ <span className="font-mono tabular-nums">{formatElapsed(log.elapsedMs)}</span>
276
+ </span>
277
+ ))}
272
278
 
273
279
  {/* Streaming timing */}
274
280
  {log.streaming && firstChunkLabel !== null && (
@@ -367,12 +373,12 @@ export const LogEntryHeader = memo(function ({
367
373
  )}
368
374
 
369
375
  {/* Client info (PID + project folder) */}
370
- {(log.clientPid !== null || log.clientProjectFolder !== null) && (
376
+ {(hasClientPid || hasClientProjectFolder) && (
371
377
  <Tooltip>
372
378
  <TooltipTrigger asChild>
373
379
  <span className="hidden xl:flex items-center gap-1 text-purple-400/80 text-xs shrink-0">
374
380
  <FileTerminal className="size-3" />
375
- {log.clientProjectFolder !== null ? (
381
+ {hasClientProjectFolder ? (
376
382
  <span className="font-mono tabular-nums">{log.clientProjectFolder}</span>
377
383
  ) : (
378
384
  <span className="font-mono tabular-nums">PID {log.clientPid}</span>
@@ -380,7 +386,7 @@ export const LogEntryHeader = memo(function ({
380
386
  </span>
381
387
  </TooltipTrigger>
382
388
  <TooltipContent>
383
- {log.clientCwd !== null
389
+ {hasClientCwd
384
390
  ? `PID: ${log.clientPid ?? "?"} CWD: ${log.clientCwd}`
385
391
  : `Process ID: ${log.clientPid ?? "?"}`}
386
392
  </TooltipContent>
@@ -0,0 +1,192 @@
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
+ "connection",
14
+ "keep-alive",
15
+ "proxy-authenticate",
16
+ "proxy-authorization",
17
+ "te",
18
+ "trailers",
19
+ "transfer-encoding",
20
+ "upgrade",
21
+ "content-length",
22
+ ]);
23
+
24
+ const RESPONSE_HOP_BY_HOP = new Set([
25
+ "connection",
26
+ "keep-alive",
27
+ "proxy-authenticate",
28
+ "proxy-authorization",
29
+ "te",
30
+ "trailers",
31
+ "transfer-encoding",
32
+ "upgrade",
33
+ ]);
34
+
35
+ export type IdentityProxyOptions = {
36
+ /** Public port to listen on. Clients point at this. */
37
+ listenPort: number;
38
+ /** Host the upstream TanStack Start server listens on. */
39
+ upstreamHost: string;
40
+ /** Port the upstream TanStack Start server listens on. */
41
+ upstreamPort: number;
42
+ };
43
+
44
+ /**
45
+ * Start a tiny native Node HTTP server that:
46
+ * 1. Reads the client connection's source port (`req.socket.remotePort`).
47
+ * 2. Resolves that port to a PID via the OS connection table
48
+ * (`Get-NetTCPConnection -LocalPort <port>` on Windows,
49
+ * `ss -tanp state established` on Linux/macOS).
50
+ * 3. Resolves the PID's cwd / project folder.
51
+ * 4. Injects everything as `X-Agent-Inspector-Client-*` headers.
52
+ * 5. Reverse-proxies the request to the upstream inspector (TanStack
53
+ * Start, which already reads those headers via `getClientInfo`).
54
+ *
55
+ * Why this exists: TanStack Start strips the underlying TCP socket from
56
+ * the fetch-API `Request` before it reaches the proxy handler, so the
57
+ * handler cannot read `socket.remotePort`. Sitting in front of TanStack
58
+ * Start as a native Node server restores the ability to look up the
59
+ * client PID at the socket level — no wrapper script, no client-side
60
+ * changes, no header configuration by the AI tool author.
61
+ *
62
+ * The proxy degrades gracefully when the OS lookup fails (missing
63
+ * permissions, port not found): the request is still forwarded, just
64
+ * without identity headers, so it falls back to the existing
65
+ * best-effort scan inside TanStack Start.
66
+ */
67
+ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
68
+ return new Promise((resolve, reject) => {
69
+ const server = http.createServer();
70
+ // Eagerly resolve client identity on `connection` — at that point the
71
+ // socket is fresh and the OS connection table still lists it as
72
+ // ESTABLISHED. By the time `request` fires (let alone after we finish
73
+ // reading the body) HTTP/1.0 clients have already closed their side
74
+ // and the row is in TIME_WAIT with PID 0.
75
+ server.on("connection", (socket: import("node:net").Socket) => {
76
+ const remotePort = socket.remotePort;
77
+ if (remotePort !== undefined && remotePort > 0) {
78
+ void lookupClientByPort(remotePort).catch(() => null);
79
+ }
80
+ });
81
+ server.on("request", (req, res) => {
82
+ void handleRequest(req, res, options).catch((err: unknown) => {
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ if (!res.headersSent) {
85
+ res.writeHead(502, { "content-type": "text/plain" });
86
+ }
87
+ res.end(`Identity proxy error: ${message}`);
88
+ });
89
+ });
90
+ server.once("error", reject);
91
+ server.listen(options.listenPort, () => {
92
+ server.removeListener("error", reject);
93
+ resolve(server);
94
+ });
95
+ });
96
+ }
97
+
98
+ async function handleRequest(
99
+ req: http.IncomingMessage,
100
+ res: http.ServerResponse,
101
+ options: IdentityProxyOptions,
102
+ ): Promise<void> {
103
+ // The connection-time lookup already populated the per-port cache; this
104
+ // call now hits the cache and returns the result synchronously.
105
+ const remotePort = req.socket.remotePort;
106
+ let identity: Awaited<ReturnType<typeof lookupClientByPort>> | null = null;
107
+ if (remotePort !== undefined && remotePort > 0) {
108
+ try {
109
+ identity = await lookupClientByPort(remotePort);
110
+ } catch {
111
+ identity = null;
112
+ }
113
+ }
114
+
115
+ // Buffer the request body — TanStack Start needs the raw bytes intact
116
+ // for SSE/streaming endpoints, so we can't decode-and-re-encode.
117
+ const chunks: Buffer[] = [];
118
+ for await (const chunk of req) {
119
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : toBuffer(chunk));
120
+ }
121
+ const body = Buffer.concat(chunks);
122
+
123
+ const upstreamHeaders: Record<string, string | string[]> = {};
124
+ for (const [name, value] of Object.entries(req.headers)) {
125
+ if (REQUEST_HOP_BY_HOP.has(name.toLowerCase())) continue;
126
+ if (value === undefined) continue;
127
+ upstreamHeaders[name] = value;
128
+ }
129
+ if (identity !== null && identity.pid !== null) {
130
+ upstreamHeaders[IDENTITY_HEADERS.pid] = String(identity.pid);
131
+ if (identity.port !== null) {
132
+ upstreamHeaders[IDENTITY_HEADERS.port] = String(identity.port);
133
+ }
134
+ if (identity.cwd !== null) {
135
+ upstreamHeaders[IDENTITY_HEADERS.cwd] = identity.cwd;
136
+ }
137
+ if (identity.projectFolder !== null) {
138
+ upstreamHeaders[IDENTITY_HEADERS.projectFolder] = identity.projectFolder;
139
+ }
140
+ }
141
+
142
+ process.stderr.write(
143
+ `[identity-proxy] req ${req.method} ${req.url} remotePort=${remotePort} pid=${identity?.pid ?? "?"}\n`,
144
+ );
145
+ process.stderr.write(
146
+ `[identity-proxy] forwarding headers: pid=${upstreamHeaders[IDENTITY_HEADERS.pid] ?? "?"} cwd=${upstreamHeaders[IDENTITY_HEADERS.cwd] ?? "?"}\n`,
147
+ );
148
+
149
+ const proxyReq = http.request(
150
+ {
151
+ hostname: options.upstreamHost,
152
+ port: options.upstreamPort,
153
+ path: req.url ?? "/",
154
+ method: req.method,
155
+ headers: upstreamHeaders,
156
+ },
157
+ (proxyRes) => {
158
+ const outHeaders: Record<string, string | string[]> = {};
159
+ for (const [name, value] of Object.entries(proxyRes.headers)) {
160
+ if (RESPONSE_HOP_BY_HOP.has(name.toLowerCase())) continue;
161
+ if (value === undefined) continue;
162
+ outHeaders[name] = value;
163
+ }
164
+ res.writeHead(proxyRes.statusCode ?? 502, outHeaders);
165
+ proxyRes.pipe(res);
166
+ },
167
+ );
168
+ proxyReq.on("error", (err) => {
169
+ if (!res.headersSent) {
170
+ res.writeHead(502, { "content-type": "text/plain" });
171
+ }
172
+ res.end(`Upstream error: ${err.message}`);
173
+ });
174
+ if (body.length > 0) proxyReq.write(body);
175
+ proxyReq.end();
176
+ }
177
+
178
+ // `Readable` chunks in Node are typed as `any` (they can be Buffer | string
179
+ // depending on the encoding setting). We narrow them via runtime checks.
180
+ function toBuffer(chunk: unknown): Buffer {
181
+ if (Buffer.isBuffer(chunk)) return chunk;
182
+ if (typeof chunk === "string") return Buffer.from(chunk);
183
+ // `for await` over a Node Readable always yields Buffer | string; this
184
+ // branch is only reachable if a custom stream returns something else
185
+ // (Uint8Array / ArrayBuffer / etc.). The Buffer constructor accepts an
186
+ // ArrayBuffer directly; we attempt that first, then fall back to a
187
+ // best-effort empty buffer so we never throw on an unexpected chunk.
188
+ if (chunk instanceof ArrayBuffer) return Buffer.from(chunk);
189
+ if (chunk instanceof Uint8Array)
190
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
191
+ return Buffer.alloc(0);
192
+ }