@tonyclaw/agent-inspector 2.1.8 → 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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-CHkT7kun.js → CompareDrawer-BaGKK2R7.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-BiZ2_tMC.js → ProxyViewerContainer-Cxwizw8H.js} +35 -35
- package/.output/public/assets/{ReplayDialog-C85cakjx.js → ReplayDialog-Io8Ufsif.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-Dn_pMMoX.js → RequestAnatomy-CiZE_fKZ.js} +1 -1
- package/.output/public/assets/{ResponseView-DzcxCUBf.js → ResponseView-TJwxn-lh.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-BamtvkgZ.js → StreamingChunkSequence-D-9ysFSG.js} +1 -1
- package/.output/public/assets/_sessionId-3JCekYV3.js +1 -0
- package/.output/public/assets/index-B3EOwfg7.js +1 -0
- package/.output/public/assets/index-CmtfjQPv.css +1 -0
- package/.output/public/assets/{main-Dc2qVWpb.js → main-JvXpk_ov.js} +2 -2
- package/.output/server/{_sessionId-BnimGger.mjs → _sessionId-DSZ76cjr.mjs} +3 -3
- package/.output/server/_ssr/{CompareDrawer-CwVFykVF.mjs → CompareDrawer-BcUJ7lNL.mjs} +3 -3
- package/.output/server/_ssr/{ProxyViewerContainer-X3S2TWBJ.mjs → ProxyViewerContainer-DEtSh7OB.mjs} +87 -93
- package/.output/server/_ssr/{ReplayDialog-BxC12XUj.mjs → ReplayDialog-DwSynSXP.mjs} +4 -4
- package/.output/server/_ssr/{RequestAnatomy-CWWS8Qh7.mjs → RequestAnatomy-C6LGadAw.mjs} +3 -3
- package/.output/server/_ssr/{ResponseView-BewErHF-.mjs → ResponseView-Dr27DXbx.mjs} +3 -3
- package/.output/server/_ssr/{StreamingChunkSequence-BlmCVryW.mjs → StreamingChunkSequence-CDTaUWjj.mjs} +3 -3
- package/.output/server/_ssr/{index-5qbGuoFX.mjs → index-D803rHfz.mjs} +3 -3
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-s78S54MU.mjs → router-D7dDibPa.mjs} +137 -120
- package/.output/server/{_tanstack-start-manifest_v-DTxpr3KE.mjs → _tanstack-start-manifest_v-P9lKyaOf.mjs} +1 -1
- package/.output/server/index.mjs +60 -60
- package/package.json +1 -1
- package/src/components/proxy-viewer/ConversationGroup.tsx +3 -9
- package/src/components/proxy-viewer/ConversationHeader.tsx +65 -39
- package/src/proxy/socketTracker.ts +265 -27
- package/.output/public/assets/_sessionId-xBBbFH1M.js +0 -1
- package/.output/public/assets/index-6udjwQO1.js +0 -1
- package/.output/public/assets/index-DP4NzCZ5.css +0 -1
|
@@ -16,18 +16,11 @@ import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
|
|
|
16
16
|
import { formatTimestampRange } from "../../lib/timeDisplay";
|
|
17
17
|
import { cn, formatTokens } from "../../lib/utils";
|
|
18
18
|
import type { CapturedLog } from "../../contracts";
|
|
19
|
-
import { Badge } from "../ui/badge";
|
|
20
19
|
import { ConfirmDialog } from "../ui/confirm-dialog";
|
|
21
20
|
import { ProviderLogo, type Provider } from "../providers/ProviderLogo";
|
|
22
21
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
|
23
22
|
import { resolveLogFormat } from "./log-formats";
|
|
24
23
|
|
|
25
|
-
const API_FORMAT_LABELS: Record<"anthropic" | "openai" | "unknown", string> = {
|
|
26
|
-
anthropic: "Anthropic",
|
|
27
|
-
openai: "OpenAI",
|
|
28
|
-
unknown: "Unknown",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
24
|
export type ConversationHeaderProps = {
|
|
32
25
|
conversationId: string;
|
|
33
26
|
startTime: string;
|
|
@@ -35,12 +28,8 @@ export type ConversationHeaderProps = {
|
|
|
35
28
|
totalCalls: number;
|
|
36
29
|
totalInputTokens: number;
|
|
37
30
|
totalOutputTokens: number;
|
|
38
|
-
apiFormat: "anthropic" | "openai" | "unknown";
|
|
39
31
|
expanded: boolean;
|
|
40
32
|
onToggle: () => void;
|
|
41
|
-
/** Hide the API format badge on the header (used when the group contains
|
|
42
|
-
* mixed formats - the per-log badges are shown instead). */
|
|
43
|
-
hideApiFormat?: boolean;
|
|
44
33
|
/** Unique providers whose models appear in this group, with their distinct
|
|
45
34
|
* model names. Rendered as a row of provider logos next to the header —
|
|
46
35
|
* one per provider so model-switching inside a single session is visible
|
|
@@ -51,6 +40,16 @@ export type ConversationHeaderProps = {
|
|
|
51
40
|
isLoading?: boolean;
|
|
52
41
|
/** User-Agent string from the first log in the group. */
|
|
53
42
|
userAgent?: string | null;
|
|
43
|
+
/** Process ID of the client that issued the first log in the group, used
|
|
44
|
+
* to render the session identifier. When `conversationId` is a real
|
|
45
|
+
* session id this is shown as a secondary pill; when `conversationId` is
|
|
46
|
+
* a fallback (no sessionId was emitted by the client, e.g. `PID:123|/cwd`)
|
|
47
|
+
* the PID is shown as the primary identifier instead. */
|
|
48
|
+
clientPid?: number | null;
|
|
49
|
+
/** Project folder of the client that issued the first log in the group.
|
|
50
|
+
* Only shown as a secondary label when the session lacks a real
|
|
51
|
+
* `sessionId` (the same fallback path used for `clientPid`). */
|
|
52
|
+
clientProjectFolder?: string | null;
|
|
54
53
|
/** Controls whether timestamps render as compact local time or full ISO strings. */
|
|
55
54
|
timeDisplayFormat: TimeDisplayFormat;
|
|
56
55
|
/** Clear all logs in this group. After confirmation the parent removes them
|
|
@@ -65,13 +64,13 @@ export function ConversationHeader({
|
|
|
65
64
|
totalCalls,
|
|
66
65
|
totalInputTokens,
|
|
67
66
|
totalOutputTokens,
|
|
68
|
-
apiFormat,
|
|
69
67
|
expanded,
|
|
70
68
|
onToggle,
|
|
71
|
-
hideApiFormat = false,
|
|
72
69
|
providers,
|
|
73
70
|
isLoading = false,
|
|
74
71
|
userAgent,
|
|
72
|
+
clientPid,
|
|
73
|
+
clientProjectFolder,
|
|
75
74
|
timeDisplayFormat,
|
|
76
75
|
onClear,
|
|
77
76
|
}: ConversationHeaderProps): JSX.Element {
|
|
@@ -122,17 +121,59 @@ export function ConversationHeader({
|
|
|
122
121
|
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
123
122
|
)}
|
|
124
123
|
|
|
125
|
-
{/*
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
124
|
+
{/* Session identifier.
|
|
125
|
+
- Real session id (e.g. `sess_abc123...`) → render the id with the
|
|
126
|
+
standard ellipsis truncation; show `PID <n>` as a secondary pill
|
|
127
|
+
when we know the originating process.
|
|
128
|
+
- Fallback id (no sessionId was emitted) → the conversationId is
|
|
129
|
+
already a synthesized `PID:<n>|<folder>` marker; render the PID
|
|
130
|
+
as the primary identifier (purple, same weight as a real id)
|
|
131
|
+
and the project folder as a secondary muted label. */}
|
|
132
|
+
{conversationId.startsWith("PID:") || conversationId.includes("|") ? (
|
|
133
|
+
<>
|
|
134
|
+
{clientPid !== null && clientPid !== undefined ? (
|
|
135
|
+
<span
|
|
136
|
+
className="text-purple-400/90 font-mono text-xs font-semibold shrink-0"
|
|
137
|
+
title={`PID ${clientPid}`}
|
|
138
|
+
>
|
|
139
|
+
PID {clientPid}
|
|
140
|
+
</span>
|
|
141
|
+
) : (
|
|
142
|
+
<span className="text-purple-400/90 font-mono text-xs font-semibold shrink-0">
|
|
143
|
+
{conversationId}
|
|
144
|
+
</span>
|
|
145
|
+
)}
|
|
146
|
+
{clientProjectFolder !== null &&
|
|
147
|
+
clientProjectFolder !== undefined &&
|
|
148
|
+
clientProjectFolder !== "" && (
|
|
149
|
+
<span
|
|
150
|
+
className="text-muted-foreground text-xs font-mono truncate max-w-[200px] shrink-0"
|
|
151
|
+
title={clientProjectFolder}
|
|
152
|
+
>
|
|
153
|
+
{clientProjectFolder}
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</>
|
|
157
|
+
) : (
|
|
158
|
+
<>
|
|
159
|
+
<span
|
|
160
|
+
className="text-purple-400/90 font-mono text-xs font-semibold shrink-0"
|
|
161
|
+
title={conversationId}
|
|
162
|
+
>
|
|
163
|
+
{conversationId.length > 24
|
|
164
|
+
? conversationId.slice(0, 12) + "..." + conversationId.slice(-12)
|
|
165
|
+
: conversationId}
|
|
166
|
+
</span>
|
|
167
|
+
{clientPid !== null && clientPid !== undefined && (
|
|
168
|
+
<span
|
|
169
|
+
className="text-muted-foreground text-xs font-mono tabular-nums shrink-0 px-1.5 py-0.5 rounded border border-border bg-muted/40"
|
|
170
|
+
title={`Client process ID ${clientPid}`}
|
|
171
|
+
>
|
|
172
|
+
PID {clientPid}
|
|
173
|
+
</span>
|
|
174
|
+
)}
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
136
177
|
|
|
137
178
|
{/* User-Agent */}
|
|
138
179
|
{userAgent !== null && userAgent !== undefined && userAgent !== "" && (
|
|
@@ -145,21 +186,6 @@ export function ConversationHeader({
|
|
|
145
186
|
</span>
|
|
146
187
|
)}
|
|
147
188
|
|
|
148
|
-
{/* API Format Badge */}
|
|
149
|
-
{!hideApiFormat && (
|
|
150
|
-
<Badge
|
|
151
|
-
variant="outline"
|
|
152
|
-
className={cn(
|
|
153
|
-
"text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
|
|
154
|
-
apiFormat === "openai" && "border-blue-500/40 text-blue-400",
|
|
155
|
-
apiFormat === "anthropic" && "border-orange-500/40 text-orange-400",
|
|
156
|
-
apiFormat === "unknown" && "border-muted text-muted-foreground",
|
|
157
|
-
)}
|
|
158
|
-
>
|
|
159
|
-
{API_FORMAT_LABELS[apiFormat]}
|
|
160
|
-
</Badge>
|
|
161
|
-
)}
|
|
162
|
-
|
|
163
189
|
{/* Provider logos — one per unique provider used in this session. When
|
|
164
190
|
a session switched models mid-stream (e.g. claude-3-5 → deepseek-v4)
|
|
165
191
|
multiple logos appear side-by-side; tooltip lists every distinct
|
|
@@ -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
|
-
*
|
|
58
|
-
*
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
230
|
-
*
|
|
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
|
-
|
|
233
|
-
const port =
|
|
234
|
-
|
|
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
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
const existing = inflight.get(port);
|
|
483
|
+
const existing = recentInflight;
|
|
244
484
|
if (existing) return existing;
|
|
245
485
|
|
|
246
|
-
const promise =
|
|
486
|
+
const promise = lookupRecentClient()
|
|
247
487
|
.then((info) => {
|
|
248
|
-
|
|
249
|
-
|
|
488
|
+
setRecentClientCache(info);
|
|
489
|
+
recentInflight = null;
|
|
250
490
|
return info;
|
|
251
491
|
})
|
|
252
492
|
.catch((err) => {
|
|
253
|
-
|
|
254
|
-
logger.debug(`[socketTracker]
|
|
255
|
-
|
|
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
|
-
|
|
498
|
+
recentInflight = promise;
|
|
261
499
|
return promise;
|
|
262
500
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{R as s,j as e}from"./main-Dc2qVWpb.js";import{P as i}from"./ProxyViewerContainer-BiZ2_tMC.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-BiZ2_tMC.js";import"./main-Dc2qVWpb.js";const r=o;export{r as component};
|