@tonyclaw/llm-inspector 1.8.0 → 1.9.1

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/nitro.json +1 -1
  2. package/.output/public/assets/{index-DH3FOgcK.js → index-BmEH5jeO.js} +18 -18
  3. package/.output/public/assets/{index-BLVa7n9b.css → index-DdJSLfxK.css} +1 -1
  4. package/.output/public/assets/{main-Beo3LJDa.js → main-GVpFMVGE.js} +1 -1
  5. package/.output/server/_ssr/{index-HkueJ4Un.mjs → index-D0d6QxPt.mjs} +85 -31
  6. package/.output/server/_ssr/index.mjs +2 -2
  7. package/.output/server/_ssr/{router-DTswxb7l.mjs → router-D9uLXa9A.mjs} +287 -67
  8. package/.output/server/{_tanstack-start-manifest_v-DhUuivt-.mjs → _tanstack-start-manifest_v-ByfnNZV_.mjs} +1 -1
  9. package/.output/server/index.mjs +26 -26
  10. package/README.md +8 -209
  11. package/package.json +1 -1
  12. package/src/components/ProxyViewerContainer.tsx +10 -1
  13. package/src/components/providers/ProviderCard.tsx +19 -15
  14. package/src/components/providers/ProviderForm.tsx +21 -0
  15. package/src/components/proxy-viewer/LogEntry.tsx +7 -0
  16. package/src/components/proxy-viewer/ResponseView.tsx +32 -6
  17. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +12 -3
  18. package/src/proxy/chunkStorage.ts +4 -6
  19. package/src/proxy/formats/anthropic/schemas.ts +9 -0
  20. package/src/proxy/formats/anthropic/stream.ts +11 -0
  21. package/src/proxy/formats/openai/stream.ts +15 -0
  22. package/src/proxy/handler.ts +44 -27
  23. package/src/proxy/logIndex.ts +73 -7
  24. package/src/proxy/logger.ts +62 -6
  25. package/src/proxy/providers.ts +5 -0
  26. package/src/proxy/schemas.ts +2 -0
  27. package/src/proxy/socketTracker.ts +90 -36
  28. package/src/proxy/store.ts +32 -18
  29. package/src/routes/api/providers.$providerId.ts +1 -0
  30. package/src/routes/api/providers.ts +2 -0
@@ -133,6 +133,17 @@ export function extractAnthropicStream(
133
133
  stopSequence = data.delta.stop_sequence ?? null;
134
134
  outputTokens = data.usage.output_tokens;
135
135
  log.outputTokens = outputTokens;
136
+ // Cache tokens may also be present in message_delta usage
137
+ if (data.usage.cache_creation_input_tokens !== undefined) {
138
+ log.cacheCreationInputTokens = data.usage.cache_creation_input_tokens;
139
+ }
140
+ if (data.usage.cache_read_input_tokens !== undefined) {
141
+ log.cacheReadInputTokens = data.usage.cache_read_input_tokens;
142
+ }
143
+ break;
144
+ case "error":
145
+ // Store error info on the log for display
146
+ log.error = data.error.message;
136
147
  break;
137
148
  case "content_block_stop":
138
149
  case "message_stop":
@@ -42,6 +42,21 @@ export function extractOpenAIStream(
42
42
 
43
43
  try {
44
44
  const parsed: unknown = JSON.parse(dataStr);
45
+ // Check for error object in SSE stream before trying to parse as chunk
46
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
47
+ const errorDesc = Object.getOwnPropertyDescriptor(parsed, "error");
48
+ if (
49
+ errorDesc !== undefined &&
50
+ typeof errorDesc.value === "object" &&
51
+ errorDesc.value !== null
52
+ ) {
53
+ const msgDesc = Object.getOwnPropertyDescriptor(errorDesc.value, "message");
54
+ if (msgDesc !== undefined && typeof msgDesc.value === "string") {
55
+ log.error = msgDesc.value;
56
+ }
57
+ continue;
58
+ }
59
+ }
45
60
  const chunkResult = OpenAISSERawChunkSchema.safeParse(parsed);
46
61
  if (!chunkResult.success) continue;
47
62
  const chunk = chunkResult.data;
@@ -1,5 +1,5 @@
1
1
  import { createLog, emitLogUpdate, type CapturedLog } from "./store";
2
- import { appendLogEntry } from "./logger";
2
+ import { appendLogEntry, initLogger, logger } from "./logger";
3
3
  import { writeChunks } from "./chunkStorage";
4
4
  import { extractModelFromBody, type RequestFormat } from "./schemas";
5
5
  import { registry } from "./formats";
@@ -28,6 +28,16 @@ import {
28
28
  STATUS_BAD_GATEWAY,
29
29
  } from "./constants";
30
30
 
31
+ // Initialize logger at startup
32
+ initLogger()
33
+ .then(() => {
34
+ logger.info("Proxy handler initialized");
35
+ })
36
+ .catch((err) => {
37
+ // eslint-disable-next-line no-console
38
+ console.error("[handler] Logger initialization failed:", err);
39
+ });
40
+
31
41
  /**
32
42
  * Strips all custom/non-standard headers from the request and replaces with
33
43
  * unified proxy identity. Only preserves standard HTTP headers needed for API calls.
@@ -89,6 +99,7 @@ function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string
89
99
  clientProjectFolder: log.clientProjectFolder,
90
100
  streamingChunks: log.streamingChunks,
91
101
  streamingChunksPath: log.streamingChunksPath,
102
+ error: log.error,
92
103
  };
93
104
  }
94
105
 
@@ -252,7 +263,7 @@ function handleStreamingResponse(
252
263
  );
253
264
  log.streamingChunksPath = chunkPath;
254
265
  }
255
- appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: null });
266
+ appendLogEntry(buildFileLogEntry(log, upstreamUrl));
256
267
  emitLogUpdate(log);
257
268
  },
258
269
  });
@@ -264,18 +275,23 @@ function handleStreamingResponse(
264
275
  const loggedStream = upstreamRes.body.pipeThrough(transform);
265
276
 
266
277
  req.signal?.addEventListener("abort", () => {
267
- if (log.responseText === null && chunks.length > 0) {
268
- const full = chunks.join("");
278
+ if (log.responseText === null) {
279
+ logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
269
280
  log.elapsedMs = Date.now() - startTime;
270
- log.responseText = formatHandler.extractStream(full, log, log.model ?? undefined, true);
271
- // Persist chunks to disk on abort
272
- if (log.streamingChunks && log.streamingChunks.chunks.length > 0) {
273
- const chunkPath = writeChunks(
274
- log.id,
275
- log.streamingChunks.chunks,
276
- log.streamingChunks.truncated,
277
- );
278
- log.streamingChunksPath = chunkPath;
281
+ if (chunks.length > 0) {
282
+ const full = chunks.join("");
283
+ log.responseText = formatHandler.extractStream(full, log, log.model ?? undefined, true);
284
+ // Persist chunks to disk on abort
285
+ if (log.streamingChunks && log.streamingChunks.chunks.length > 0) {
286
+ const chunkPath = writeChunks(
287
+ log.id,
288
+ log.streamingChunks.chunks,
289
+ log.streamingChunks.truncated,
290
+ );
291
+ log.streamingChunksPath = chunkPath;
292
+ }
293
+ } else {
294
+ log.responseText = "Client aborted";
279
295
  }
280
296
  appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
281
297
  emitLogUpdate(log);
@@ -296,15 +312,17 @@ export async function handleProxy(req: Request): Promise<Response> {
296
312
  const url = new URL(req.url);
297
313
  const parsed = parseRequestPath(req, url);
298
314
 
315
+ // Read body only after cheap path checks
299
316
  let requestBody: string | null = null;
300
317
  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
301
318
  requestBody = await req.text();
302
319
  }
303
320
 
304
- // Extract model once and reuse
321
+ // Extract model once and reuse - avoid duplicate parsing
305
322
  const model = requestBody !== null ? extractModelFromBody(requestBody) : null;
306
323
 
307
- const matchedProviderConfig = findProviderByModelFromConfig(requestBody);
324
+ // Find provider config using already-extracted model (not calling extractModelFromBody again)
325
+ const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
308
326
  const upstreamBase = selectUpstreamBase(parsed.isChatCompletions, matchedProviderConfig);
309
327
  const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
310
328
  const upstreamHost = getHostFromUrl(upstreamBase);
@@ -318,6 +336,7 @@ export async function handleProxy(req: Request): Promise<Response> {
318
336
 
319
337
  // Only proxy requests matching a registered provider
320
338
  if (model === null || provider === null) {
339
+ logger.warn(`[handler] Unsupported provider: model=${model}`);
321
340
  return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
322
341
  }
323
342
 
@@ -354,9 +373,19 @@ export async function handleProxy(req: Request): Promise<Response> {
354
373
  method: req.method,
355
374
  headers: upstreamHeaders,
356
375
  body: requestBody,
376
+ signal: req.signal,
357
377
  });
358
378
  } catch (err) {
359
379
  log.elapsedMs = Date.now() - startTime;
380
+ // Check if it was a client abort (not a proxy error)
381
+ if (err instanceof Error && err.name === "AbortError") {
382
+ logger.info(`[handler] Client aborted: ${req.method} ${parsed.apiPath}`);
383
+ log.responseStatus = 499; // Client Closed Request (non-standard but descriptive)
384
+ log.responseText = "Client aborted";
385
+ appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
386
+ return new Response("Client aborted", { status: 499 });
387
+ }
388
+ logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, String(err));
360
389
  log.responseStatus = STATUS_BAD_GATEWAY;
361
390
  log.responseText = String(err);
362
391
  appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: String(err) });
@@ -380,15 +409,3 @@ export async function handleProxy(req: Request): Promise<Response> {
380
409
 
381
410
  return handleStreamingResponse(upstreamRes, req, startTime, formatHandler, upstreamUrl, log);
382
411
  }
383
-
384
- /**
385
- * Find provider configuration by model name (from persistent store).
386
- */
387
- function findProviderByModelFromConfig(
388
- requestBody: string | null,
389
- ): ReturnType<typeof findProviderByModel> {
390
- if (requestBody === null) return null;
391
- const model = extractModelFromBody(requestBody);
392
- if (model === null) return null;
393
- return findProviderByModel(model);
394
- }
@@ -2,7 +2,7 @@ import { readFile, writeFile, stat, readdir, mkdir } from "node:fs/promises";
2
2
  import { createReadStream, existsSync } from "node:fs";
3
3
  import { join, dirname } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
- import { resolveLogDir } from "./logger";
5
+ import { resolveLogDir, logger } from "./logger";
6
6
 
7
7
  type LogIndexEntry = {
8
8
  id: number;
@@ -71,8 +71,7 @@ export async function loadIndex(): Promise<LogIndex> {
71
71
  }
72
72
  return cachedIndex;
73
73
  } catch (err) {
74
- // eslint-disable-next-line no-console
75
- console.error("[logIndex] Failed to load index:", err);
74
+ logger.error("[logIndex] Failed to load index:", String(err));
76
75
  cachedIndex = createEmptyIndex();
77
76
  return cachedIndex;
78
77
  }
@@ -91,8 +90,7 @@ export async function saveIndex(index: LogIndex): Promise<void> {
91
90
  try {
92
91
  await writeFile(indexPath, JSON.stringify(index), "utf-8");
93
92
  } catch (err) {
94
- // eslint-disable-next-line no-console
95
- console.error("[logIndex] Failed to save index:", err);
93
+ logger.error("[logIndex] Failed to save index:", String(err));
96
94
  }
97
95
  }
98
96
 
@@ -107,6 +105,36 @@ export async function addToIndex(
107
105
  if (id > index.maxId) {
108
106
  index.maxId = id;
109
107
  }
108
+ // Defer disk writes to reduce I/O - flush after a batch of updates
109
+ scheduleIndexFlush();
110
+ }
111
+
112
+ // Batch writes: collect pending flushes and write once
113
+ let indexFlushScheduled = false;
114
+ let indexFlushTimeout: ReturnType<typeof setTimeout> | null = null;
115
+
116
+ async function flushIndexAsync(): Promise<void> {
117
+ indexFlushScheduled = false;
118
+ indexFlushTimeout = null;
119
+ const index = await loadIndex();
120
+ await saveIndex(index);
121
+ }
122
+
123
+ function scheduleIndexFlush(): void {
124
+ if (indexFlushScheduled) return;
125
+ indexFlushScheduled = true;
126
+ indexFlushTimeout = setTimeout(() => {
127
+ void flushIndexAsync();
128
+ }, 1000); // Flush after 1 second of inactivity
129
+ }
130
+
131
+ export async function flushIndex(): Promise<void> {
132
+ if (indexFlushTimeout !== null) {
133
+ clearTimeout(indexFlushTimeout);
134
+ indexFlushTimeout = null;
135
+ }
136
+ indexFlushScheduled = false;
137
+ const index = await loadIndex();
110
138
  await saveIndex(index);
111
139
  }
112
140
 
@@ -173,9 +201,47 @@ export async function rebuildIndex(): Promise<LogIndex> {
173
201
  return newIndex;
174
202
  }
175
203
 
204
+ // Async mutex for atomic ID generation to prevent race conditions
205
+ // Uses a promise queue instead of busy-waiting
206
+ let idGenerationPromise: Promise<void> | null = null;
207
+ let releaseLock: (() => void) | null = null;
208
+
209
+ async function acquireLock(): Promise<void> {
210
+ if (releaseLock === null) {
211
+ idGenerationPromise = new Promise<void>((resolve) => {
212
+ releaseLock = resolve;
213
+ });
214
+ } else {
215
+ // Wait for the previous lock to be released
216
+ await idGenerationPromise;
217
+ // After waiting, we need to create a new promise for the next waiter
218
+ idGenerationPromise = new Promise<void>((resolve) => {
219
+ releaseLock = resolve;
220
+ });
221
+ }
222
+ }
223
+
224
+ function releaseLockFn(): void {
225
+ if (releaseLock) {
226
+ const resolve = releaseLock;
227
+ releaseLock = null;
228
+ idGenerationPromise = null;
229
+ resolve();
230
+ }
231
+ }
232
+
176
233
  export async function getNextLogId(): Promise<number> {
177
- const index = await loadIndex();
178
- return index.maxId + 1;
234
+ await acquireLock();
235
+ try {
236
+ const index = await loadIndex();
237
+ const nextId = index.maxId + 1;
238
+ index.maxId = nextId;
239
+ // Synchronously update the index in memory (disk write is deferred via batching)
240
+ cachedIndex = index;
241
+ return nextId;
242
+ } finally {
243
+ releaseLockFn();
244
+ }
179
245
  }
180
246
 
181
247
  export function getCurrentLogFile(): string {
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
 
4
4
  const LOG_DIR_ENV = process.env["LOG_DIR"];
5
5
  const RETENTION_DAYS = Number(process.env["LOG_RETENTION_DAYS"] ?? "7");
6
+ const LOG_FILE_ENV = process.env["LLM_INSPECTOR_LOG_FILE"];
6
7
 
7
8
  function getUserDataDir(): string {
8
9
  if (process.platform === "win32") {
@@ -35,6 +36,14 @@ export function getLogFilePath(): string {
35
36
  return path.join(resolveLogDir(), `${yyyy}-${mm}-${dd}.jsonl`);
36
37
  }
37
38
 
39
+ function getInspectorLogPath(): string {
40
+ if (LOG_FILE_ENV !== undefined) {
41
+ return LOG_FILE_ENV;
42
+ }
43
+ const base = getUserDataDir();
44
+ return path.join(base, ".llm-inspector", "logs", "inspector.log");
45
+ }
46
+
38
47
  export async function initLogger(): Promise<void> {
39
48
  const dir = resolveLogDir();
40
49
  const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
@@ -50,24 +59,63 @@ export async function initLogger(): Promise<void> {
50
59
  if (s.mtimeMs < cutoff) {
51
60
  await unlink(fullPath);
52
61
  }
53
- } catch (err) {
54
- // eslint-disable-next-line no-console
55
- console.error("[logger] Failed to stat/remove log file:", err);
62
+ } catch {
63
+ // Skip files that can't be stat - not critical
56
64
  }
57
65
  }
58
66
  } catch (err) {
67
+ // Log directory initialization errors but don't fail startup
68
+ // eslint-disable-next-line no-console
69
+ console.error("[logger] Failed to initialize log directory:", err);
70
+ }
71
+ }
72
+
73
+ // File-based logger for application logs (not log entries)
74
+ async function writeAppLog(message: string): Promise<void> {
75
+ try {
76
+ const logPath = getInspectorLogPath();
77
+ const logDirPath = path.dirname(logPath);
78
+ await mkdir(logDirPath, { recursive: true });
79
+ const timestamp = new Date().toISOString();
80
+ await appendFile(logPath, `[${timestamp}] ${message}\n`, "utf-8");
81
+ } catch (err) {
82
+ // Log to stderr since file logging failed
59
83
  // eslint-disable-next-line no-console
60
- console.error("[logger] Failed to initialize logger:", err);
84
+ console.error(`[logger] Failed to write to ${getInspectorLogPath()}:`, err);
61
85
  }
62
86
  }
63
87
 
88
+ // Application logger - writes to inspector.log
89
+ export const logger = {
90
+ debug(message: string, ...args: unknown[]): void {
91
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
92
+ void writeAppLog(`[DEBUG] ${msg}`);
93
+ },
94
+ info(message: string, ...args: unknown[]): void {
95
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
96
+ void writeAppLog(`[INFO] ${msg}`);
97
+ },
98
+ warn(message: string, ...args: unknown[]): void {
99
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
100
+ void writeAppLog(`[WARN] ${msg}`);
101
+ },
102
+ error(message: string, ...args: unknown[]): void {
103
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
104
+ void writeAppLog(`[ERROR] ${msg}`);
105
+ },
106
+ };
107
+
64
108
  // Write buffer for batching async writes
109
+ const MAX_BUFFER_SIZE = 1000; // Prevent unbounded memory growth
65
110
  let writeBuffer: string[] = [];
66
111
  let writeScheduled = false;
112
+ let isFlushing = false; // Prevent concurrent flush operations
67
113
 
68
114
  async function flushWriteBuffer(): Promise<void> {
69
115
  if (writeBuffer.length === 0) return;
116
+ if (isFlushing) return; // Skip if another flush is in progress
70
117
 
118
+ isFlushing = true;
71
119
  const toWrite = writeBuffer.join("");
72
120
  writeBuffer = [];
73
121
 
@@ -76,8 +124,11 @@ async function flushWriteBuffer(): Promise<void> {
76
124
  await mkdir(path.dirname(filePath), { recursive: true });
77
125
  await appendFile(filePath, toWrite, "utf-8");
78
126
  } catch (err) {
127
+ // Log write buffer errors but don't throw to prevent cascading failures
79
128
  // eslint-disable-next-line no-console
80
- console.error("[logger] Failed to write log entries:", err);
129
+ console.error("[logger] Failed to flush write buffer:", err);
130
+ } finally {
131
+ isFlushing = false;
81
132
  }
82
133
  }
83
134
 
@@ -95,5 +146,10 @@ function scheduleFlush(): void {
95
146
  export function appendLogEntry(entry: Record<string, unknown>): void {
96
147
  const line = JSON.stringify(entry) + "\n";
97
148
  writeBuffer.push(line);
98
- scheduleFlush();
149
+ // Flush immediately if buffer exceeds max size to prevent OOM
150
+ if (writeBuffer.length >= MAX_BUFFER_SIZE) {
151
+ void flushWriteBuffer();
152
+ } else {
153
+ scheduleFlush();
154
+ }
99
155
  }
@@ -17,6 +17,8 @@ export const ProviderConfigSchema = z.object({
17
17
  openaiBaseUrl: z.string().optional(),
18
18
  /** Auth header to use: "bearer" (default) or "x-api-key" */
19
19
  authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
20
+ /** API documentation URL */
21
+ apiDocsUrl: z.string().optional(),
20
22
  createdAt: z.string(),
21
23
  updatedAt: z.string(),
22
24
  });
@@ -142,6 +144,7 @@ export function addProvider(
142
144
  baseUrl?: string,
143
145
  model?: string,
144
146
  authHeader?: "bearer" | "x-api-key",
147
+ apiDocsUrl?: string,
145
148
  ): ProviderConfig {
146
149
  const providers = getProviders();
147
150
  const now = new Date().toISOString();
@@ -153,6 +156,7 @@ export function addProvider(
153
156
  baseUrl,
154
157
  model,
155
158
  authHeader: authHeader ?? "bearer",
159
+ apiDocsUrl,
156
160
  createdAt: now,
157
161
  updatedAt: now,
158
162
  anthropicBaseUrl: format === "anthropic" && baseUrl !== undefined ? baseUrl : "",
@@ -179,6 +183,7 @@ export function updateProvider(
179
183
  format: updates.format ?? existing.format,
180
184
  baseUrl: updates.baseUrl !== undefined ? updates.baseUrl : existing.baseUrl,
181
185
  authHeader: updates.authHeader ?? existing.authHeader,
186
+ apiDocsUrl: updates.apiDocsUrl ?? existing.apiDocsUrl,
182
187
  createdAt: existing.createdAt,
183
188
  updatedAt: new Date().toISOString(),
184
189
  // Handle format-specific URLs
@@ -61,6 +61,8 @@ export const CapturedLogSchema = z.object({
61
61
  clientProjectFolder: z.string().nullable().optional(),
62
62
  streamingChunks: StreamingChunksArraySchema.optional(),
63
63
  streamingChunksPath: z.string().nullable().optional(),
64
+ /** Error message from streaming response (e.g., SSE error event) */
65
+ error: z.string().nullable().optional(),
64
66
  });
65
67
 
66
68
  export type CapturedLog = z.infer<typeof CapturedLogSchema>;
@@ -1,5 +1,6 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
+ import { logger } from "./logger";
3
4
 
4
5
  const execAsync = promisify(exec);
5
6
 
@@ -55,30 +56,49 @@ function extractRemotePort(request: Request): number | null {
55
56
  }
56
57
 
57
58
  /**
58
- * Look up PID from a local port using netstat.
59
+ * Look up PID from a local port using platform-appropriate command.
59
60
  * Returns PID as number, or null if not found.
60
61
  */
61
62
  async function lookupPidByPort(port: number): Promise<number | null> {
63
+ const platform = process.platform;
64
+
62
65
  try {
63
- // netstat -aon finds all connections with port, -n avoids DNS resolution
64
- // Filter by ESTABLISHED connections on the specific port
65
- const { stdout } = await execAsync(`netstat -aon | findstr :${port} | findstr ESTABLISHED`, {
66
- windowsHide: true,
67
- });
68
-
69
- const lines = stdout.trim().split("\n").filter(Boolean);
70
- for (const line of lines) {
71
- // Format: TCP 127.0.0.1:12345 127.0.0.1:54321 ESTABLISHED 12345
72
- // The last space-separated token is the PID
73
- const parts = line.trim().split(/\s+/);
74
- const lastPart = parts[parts.length - 1];
75
- if (lastPart !== undefined) {
76
- const pid = parseInt(lastPart, 10);
66
+ if (platform === "win32") {
67
+ // Windows: use PowerShell Get-NetTCPConnection (faster than netstat)
68
+ const { stdout } = await execAsync(
69
+ `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -State Established | Select-Object -ExpandProperty OwningProcess"`,
70
+ { windowsHide: true },
71
+ );
72
+ const pid = parseInt(stdout.trim(), 10);
73
+ return !isNaN(pid) && pid > 0 ? pid : null;
74
+ } else if (platform === "darwin") {
75
+ // macOS: use lsof
76
+ const { stdout } = await execAsync(`lsof -i :${port} -sTCP:ESTABLISHED -t 2>/dev/null`, {
77
+ shell: "/bin/sh",
78
+ });
79
+ const pid = parseInt(stdout.trim(), 10);
80
+ return !isNaN(pid) && pid > 0 ? pid : null;
81
+ } else {
82
+ // Linux: use ss (faster than netstat) or fall back to netstat
83
+ try {
84
+ const { stdout } = await execAsync(
85
+ `ss -tlnp 'sport = :${port}' 2>/dev/null | grep ESTAB | awk '{print $6}' | grep -o 'pid=[0-9]*' | cut -d= -f2`,
86
+ { shell: "/bin/sh" },
87
+ );
88
+ const pid = parseInt(stdout.trim(), 10);
77
89
  if (!isNaN(pid) && pid > 0) return pid;
90
+ } catch {
91
+ // Fall back to netstat
78
92
  }
93
+ const { stdout } = await execAsync(
94
+ `netstat -tan 2>/dev/null | grep ':${port}' | grep ESTABLISHED | awk '{print $7}' | cut -d/ -f1`,
95
+ { shell: "/bin/sh" },
96
+ );
97
+ const pid = parseInt(stdout.trim(), 10);
98
+ return !isNaN(pid) && pid > 0 ? pid : null;
79
99
  }
80
- return null;
81
- } catch {
100
+ } catch (err) {
101
+ logger.debug(`[socketTracker] Failed to lookup PID for port ${port}:`, String(err));
82
102
  return null;
83
103
  }
84
104
  }
@@ -90,35 +110,69 @@ async function lookupPidByPort(port: number): Promise<number | null> {
90
110
  async function lookupProcessInfo(
91
111
  pid: number,
92
112
  ): Promise<{ cwd: string | null; projectFolder: string | null }> {
113
+ const platform = process.platform;
114
+
93
115
  try {
94
- // Use wmic to get the full command line
95
- const { stdout } = await execAsync(
96
- `wmic process where processid=${pid} get commandline /value`,
97
- { windowsHide: true },
98
- );
99
-
100
- const lines = stdout.trim().split("\n").filter(Boolean);
101
- for (const line of lines) {
102
- if (line.includes("=")) {
103
- const commandLine = (line.split("=")[1] ?? "").trim();
116
+ if (platform === "win32") {
117
+ // Windows: use wmic
118
+ const { stdout } = await execAsync(
119
+ `wmic process where processid=${pid} get commandline /value`,
120
+ { windowsHide: true },
121
+ );
122
+
123
+ const lines = stdout.trim().split("\n").filter(Boolean);
124
+ for (const line of lines) {
125
+ if (line.includes("=")) {
126
+ const commandLine = (line.split("=")[1] ?? "").trim();
127
+ if (commandLine) {
128
+ const exeMatch = commandLine.match(/^"([^"]+)"/) || commandLine.match(/^([^\s]+)/);
129
+ if (exeMatch && exeMatch[1] !== undefined) {
130
+ const exePath = exeMatch[1];
131
+ const exeDir = exePath.substring(0, exePath.lastIndexOf("\\"));
132
+ return {
133
+ cwd: exeDir,
134
+ projectFolder: exeDir.split("\\").pop() ?? null,
135
+ };
136
+ }
137
+ }
138
+ }
139
+ }
140
+ } else {
141
+ // Unix/Linux/macOS: read from /proc or use ps
142
+ try {
143
+ // Try reading /proc/PID/cmdline (Linux)
144
+ const { stdout } = await execAsync(`cat /proc/${pid}/cmdline 2>/dev/null | tr '\\0' ' '`, {
145
+ shell: "/bin/sh",
146
+ });
147
+ const commandLine = stdout.trim();
104
148
  if (commandLine) {
105
- // Extract CWD from command line
106
- // The command typically looks like: "C:\path\to\ai-tool\resources\app\bin\cli\win32-x64\tool.exe" ...
107
- // or "C:\Users\user\AppData\Local\Programs\Claude\Claude.exe" --some-flag
108
- // We need to find the working directory - it's usually the directory containing the exe
109
- // but we also need to check if a --parent-dir flag was passed
110
-
111
- // Parse the command line to find the executable path
112
149
  const exeMatch = commandLine.match(/^"([^"]+)"/) || commandLine.match(/^([^\s]+)/);
113
150
  if (exeMatch && exeMatch[1] !== undefined) {
114
151
  const exePath = exeMatch[1];
115
- const exeDir = exePath.substring(0, exePath.lastIndexOf("\\"));
152
+ const exeDir = exePath.substring(0, exePath.lastIndexOf("/"));
116
153
  return {
117
154
  cwd: exeDir,
118
- projectFolder: exeDir.split("\\").pop() ?? null,
155
+ projectFolder: exeDir.split("/").pop() ?? null,
119
156
  };
120
157
  }
121
158
  }
159
+ } catch {
160
+ // Fall back to ps
161
+ }
162
+
163
+ // macOS fallback
164
+ if (platform === "darwin") {
165
+ const { stdout } = await execAsync(`ps -p ${pid} -o comm= 2>/dev/null`, {
166
+ shell: "/bin/sh",
167
+ });
168
+ const exePath = stdout.trim();
169
+ if (exePath) {
170
+ const exeDir = exePath.substring(0, exePath.lastIndexOf("/"));
171
+ return {
172
+ cwd: exeDir,
173
+ projectFolder: exePath.split("/").pop() ?? null,
174
+ };
175
+ }
122
176
  }
123
177
  }
124
178
  return { cwd: null, projectFolder: null };