@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.
- package/.output/cli.js +14676 -399
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-BaGKK2R7.js → CompareDrawer-XTdSOto9.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-Cxwizw8H.js → ProxyViewerContainer-Cc944N8i.js} +19 -19
- package/.output/public/assets/{ReplayDialog-Io8Ufsif.js → ReplayDialog-Cd1VsfRe.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CiZE_fKZ.js → RequestAnatomy-QxSpbmsj.js} +1 -1
- package/.output/public/assets/{ResponseView-TJwxn-lh.js → ResponseView-8GivXCzp.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-D-9ysFSG.js → StreamingChunkSequence-CwNGqJ5n.js} +1 -1
- package/.output/public/assets/_sessionId-D89uDTQs.js +1 -0
- package/.output/public/assets/index-BsFdgXac.js +1 -0
- package/.output/public/assets/{main-JvXpk_ov.js → main-B_EcGyf-.js} +2 -2
- package/.output/server/{_sessionId-DSZ76cjr.mjs → _sessionId-CmO9read.mjs} +2 -2
- package/.output/server/_ssr/{CompareDrawer-BcUJ7lNL.mjs → CompareDrawer-D9wf870o.mjs} +2 -2
- package/.output/server/_ssr/{ProxyViewerContainer-DEtSh7OB.mjs → ProxyViewerContainer-BDLJSawP.mjs} +49 -30
- package/.output/server/_ssr/{ReplayDialog-DwSynSXP.mjs → ReplayDialog-B2dS4kmP.mjs} +3 -3
- package/.output/server/_ssr/{RequestAnatomy-C6LGadAw.mjs → RequestAnatomy-k49Peshi.mjs} +2 -2
- package/.output/server/_ssr/{ResponseView-Dr27DXbx.mjs → ResponseView-Bk3B1suu.mjs} +2 -2
- package/.output/server/_ssr/{StreamingChunkSequence-CDTaUWjj.mjs → StreamingChunkSequence-D6MDGCgh.mjs} +2 -2
- package/.output/server/_ssr/{index-D803rHfz.mjs → index-CC8KdlTb.mjs} +2 -2
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-D7dDibPa.mjs → router-BEmMnVa7.mjs} +213 -28
- package/.output/server/{_tanstack-start-manifest_v-P9lKyaOf.mjs → _tanstack-start-manifest_v-Bf_4hG4w.mjs} +1 -1
- package/.output/server/index.mjs +55 -55
- package/package.json +1 -1
- package/src/cli.ts +107 -2
- package/src/components/proxy-viewer/ConversationGroup.tsx +22 -2
- package/src/components/proxy-viewer/LogEntryHeader.tsx +35 -29
- package/src/proxy/identityProxy.ts +192 -0
- package/src/proxy/socketTracker.ts +222 -24
- package/.output/public/assets/_sessionId-3JCekYV3.js +0 -1
- package/.output/public/assets/index-B3EOwfg7.js +0 -1
package/.output/server/index.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
45
|
+
"/assets/CompareDrawer-XTdSOto9.js": {
|
|
46
46
|
"type": "text/javascript; charset=utf-8",
|
|
47
|
-
"etag": '"49e8-
|
|
48
|
-
"mtime": "2026-07-
|
|
47
|
+
"etag": '"49e8-bu14iyddw7i6hLggEbQwIlgQT/g"',
|
|
48
|
+
"mtime": "2026-07-04T05:26:41.755Z",
|
|
49
49
|
"size": 18920,
|
|
50
|
-
"path": "../public/assets/CompareDrawer-
|
|
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-
|
|
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-
|
|
59
|
+
"/assets/main-B_EcGyf-.js": {
|
|
67
60
|
"type": "text/javascript; charset=utf-8",
|
|
68
|
-
"etag": '"512fd-
|
|
69
|
-
"mtime": "2026-07-
|
|
61
|
+
"etag": '"512fd-vx5h/KMQz3ePll6B2LBWhS5rz1E"',
|
|
62
|
+
"mtime": "2026-07-04T05:26:41.755Z",
|
|
70
63
|
"size": 332541,
|
|
71
|
-
"path": "../public/assets/main-
|
|
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-
|
|
69
|
+
"mtime": "2026-07-04T05:26:41.755Z",
|
|
77
70
|
"size": 6918,
|
|
78
71
|
"path": "../public/assets/minimax-BPMzvuL-.jpeg"
|
|
79
72
|
},
|
|
80
|
-
"/assets/
|
|
73
|
+
"/assets/ReplayDialog-Cd1VsfRe.js": {
|
|
81
74
|
"type": "text/javascript; charset=utf-8",
|
|
82
|
-
"etag": '"
|
|
83
|
-
"mtime": "2026-07-
|
|
84
|
-
"size":
|
|
85
|
-
"path": "../public/assets/
|
|
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/
|
|
80
|
+
"/assets/index-BsFdgXac.js": {
|
|
88
81
|
"type": "text/javascript; charset=utf-8",
|
|
89
|
-
"etag": '"
|
|
90
|
-
"mtime": "2026-07-
|
|
91
|
-
"size":
|
|
92
|
-
"path": "../public/assets/
|
|
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-
|
|
87
|
+
"/assets/ResponseView-8GivXCzp.js": {
|
|
95
88
|
"type": "text/javascript; charset=utf-8",
|
|
96
|
-
"etag": '"30d2-
|
|
97
|
-
"mtime": "2026-07-
|
|
89
|
+
"etag": '"30d2-iJTTpaNykHiLvmnmUvp+FrIs6ks"',
|
|
90
|
+
"mtime": "2026-07-04T05:26:41.755Z",
|
|
98
91
|
"size": 12498,
|
|
99
|
-
"path": "../public/assets/ResponseView-
|
|
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-
|
|
111
|
+
"mtime": "2026-07-04T05:26:41.755Z",
|
|
105
112
|
"size": 11256,
|
|
106
113
|
"path": "../public/assets/zhipuai-BPNAnxo-.svg"
|
|
107
114
|
},
|
|
108
|
-
"/assets/
|
|
115
|
+
"/assets/_sessionId-D89uDTQs.js": {
|
|
109
116
|
"type": "text/javascript; charset=utf-8",
|
|
110
|
-
"etag": '"
|
|
111
|
-
"mtime": "2026-07-
|
|
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-
|
|
120
|
+
"path": "../public/assets/_sessionId-D89uDTQs.js"
|
|
121
121
|
},
|
|
122
|
-
"/assets/
|
|
123
|
-
"type": "
|
|
124
|
-
"etag": '"
|
|
125
|
-
"mtime": "2026-07-
|
|
126
|
-
"size":
|
|
127
|
-
"path": "../public/assets/
|
|
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-
|
|
129
|
+
"/assets/ProxyViewerContainer-Cc944N8i.js": {
|
|
130
130
|
"type": "text/javascript; charset=utf-8",
|
|
131
|
-
"etag": '"
|
|
132
|
-
"mtime": "2026-07-
|
|
133
|
-
"size":
|
|
134
|
-
"path": "../public/assets/ProxyViewerContainer-
|
|
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
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
|
-
|
|
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
|
-
|
|
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={
|
|
148
|
-
clientProjectFolder={
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
</
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
</
|
|
270
|
-
|
|
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
|
-
{(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
+
}
|