@tonyclaw/llm-inspector 1.8.0 → 1.9.0

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 (29) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{index-BLVa7n9b.css → index-DdJSLfxK.css} +1 -1
  3. package/.output/public/assets/{index-DH3FOgcK.js → index-DyKLPMPn.js} +18 -18
  4. package/.output/public/assets/{main-Beo3LJDa.js → main-Cu0oTDfX.js} +1 -1
  5. package/.output/server/_ssr/{index-HkueJ4Un.mjs → index-COIATcfa.mjs} +74 -28
  6. package/.output/server/_ssr/index.mjs +2 -2
  7. package/.output/server/_ssr/{router-DTswxb7l.mjs → router-CwmgKXBJ.mjs} +236 -65
  8. package/.output/server/{_tanstack-start-manifest_v-DhUuivt-.mjs → _tanstack-start-manifest_v-C7hQOzvX.mjs} +1 -1
  9. package/.output/server/index.mjs +23 -23
  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/proxy/chunkStorage.ts +4 -6
  18. package/src/proxy/formats/anthropic/schemas.ts +9 -0
  19. package/src/proxy/formats/anthropic/stream.ts +11 -0
  20. package/src/proxy/formats/openai/stream.ts +15 -0
  21. package/src/proxy/handler.ts +34 -27
  22. package/src/proxy/logIndex.ts +52 -7
  23. package/src/proxy/logger.ts +60 -10
  24. package/src/proxy/providers.ts +5 -0
  25. package/src/proxy/schemas.ts +2 -0
  26. package/src/proxy/socketTracker.ts +90 -36
  27. package/src/proxy/store.ts +24 -14
  28. package/src/routes/api/providers.$providerId.ts +1 -0
  29. package/src/routes/api/providers.ts +2 -0
@@ -1,5 +1,5 @@
1
1
  import { createLog, emitLogUpdate, type CapturedLog } from "./store";
2
- import { appendLogEntry } from "./logger";
2
+ import { appendLogEntry, logger } from "./logger";
3
3
  import { writeChunks } from "./chunkStorage";
4
4
  import { extractModelFromBody, type RequestFormat } from "./schemas";
5
5
  import { registry } from "./formats";
@@ -89,6 +89,7 @@ function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string
89
89
  clientProjectFolder: log.clientProjectFolder,
90
90
  streamingChunks: log.streamingChunks,
91
91
  streamingChunksPath: log.streamingChunksPath,
92
+ error: log.error,
92
93
  };
93
94
  }
94
95
 
@@ -252,7 +253,7 @@ function handleStreamingResponse(
252
253
  );
253
254
  log.streamingChunksPath = chunkPath;
254
255
  }
255
- appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: null });
256
+ appendLogEntry(buildFileLogEntry(log, upstreamUrl));
256
257
  emitLogUpdate(log);
257
258
  },
258
259
  });
@@ -264,18 +265,23 @@ function handleStreamingResponse(
264
265
  const loggedStream = upstreamRes.body.pipeThrough(transform);
265
266
 
266
267
  req.signal?.addEventListener("abort", () => {
267
- if (log.responseText === null && chunks.length > 0) {
268
- const full = chunks.join("");
268
+ if (log.responseText === null) {
269
+ logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
269
270
  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;
271
+ if (chunks.length > 0) {
272
+ const full = chunks.join("");
273
+ log.responseText = formatHandler.extractStream(full, log, log.model ?? undefined, true);
274
+ // Persist chunks to disk on abort
275
+ if (log.streamingChunks && log.streamingChunks.chunks.length > 0) {
276
+ const chunkPath = writeChunks(
277
+ log.id,
278
+ log.streamingChunks.chunks,
279
+ log.streamingChunks.truncated,
280
+ );
281
+ log.streamingChunksPath = chunkPath;
282
+ }
283
+ } else {
284
+ log.responseText = "Client aborted";
279
285
  }
280
286
  appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
281
287
  emitLogUpdate(log);
@@ -296,15 +302,17 @@ export async function handleProxy(req: Request): Promise<Response> {
296
302
  const url = new URL(req.url);
297
303
  const parsed = parseRequestPath(req, url);
298
304
 
305
+ // Read body only after cheap path checks
299
306
  let requestBody: string | null = null;
300
307
  if (req.body && req.method !== "GET" && req.method !== "HEAD") {
301
308
  requestBody = await req.text();
302
309
  }
303
310
 
304
- // Extract model once and reuse
311
+ // Extract model once and reuse - avoid duplicate parsing
305
312
  const model = requestBody !== null ? extractModelFromBody(requestBody) : null;
306
313
 
307
- const matchedProviderConfig = findProviderByModelFromConfig(requestBody);
314
+ // Find provider config using already-extracted model (not calling extractModelFromBody again)
315
+ const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
308
316
  const upstreamBase = selectUpstreamBase(parsed.isChatCompletions, matchedProviderConfig);
309
317
  const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
310
318
  const upstreamHost = getHostFromUrl(upstreamBase);
@@ -318,6 +326,7 @@ export async function handleProxy(req: Request): Promise<Response> {
318
326
 
319
327
  // Only proxy requests matching a registered provider
320
328
  if (model === null || provider === null) {
329
+ logger.warn(`[handler] Unsupported provider: model=${model}`);
321
330
  return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
322
331
  }
323
332
 
@@ -354,9 +363,19 @@ export async function handleProxy(req: Request): Promise<Response> {
354
363
  method: req.method,
355
364
  headers: upstreamHeaders,
356
365
  body: requestBody,
366
+ signal: req.signal,
357
367
  });
358
368
  } catch (err) {
359
369
  log.elapsedMs = Date.now() - startTime;
370
+ // Check if it was a client abort (not a proxy error)
371
+ if (err instanceof Error && err.name === "AbortError") {
372
+ logger.info(`[handler] Client aborted: ${req.method} ${parsed.apiPath}`);
373
+ log.responseStatus = 499; // Client Closed Request (non-standard but descriptive)
374
+ log.responseText = "Client aborted";
375
+ appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: "Client aborted" });
376
+ return new Response("Client aborted", { status: 499 });
377
+ }
378
+ logger.error(`[handler] Proxy error: ${req.method} ${parsed.apiPath}`, String(err));
360
379
  log.responseStatus = STATUS_BAD_GATEWAY;
361
380
  log.responseText = String(err);
362
381
  appendLogEntry({ ...buildFileLogEntry(log, upstreamUrl), error: String(err) });
@@ -380,15 +399,3 @@ export async function handleProxy(req: Request): Promise<Response> {
380
399
 
381
400
  return handleStreamingResponse(upstreamRes, req, startTime, formatHandler, upstreamUrl, log);
382
401
  }
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,26 @@ export async function rebuildIndex(): Promise<LogIndex> {
173
201
  return newIndex;
174
202
  }
175
203
 
204
+ // Mutex for atomic ID generation to prevent race conditions
205
+ let idGenerationLock = false;
206
+
176
207
  export async function getNextLogId(): Promise<number> {
177
- const index = await loadIndex();
178
- return index.maxId + 1;
208
+ // Wait for any ongoing ID generation to complete
209
+ while (idGenerationLock) {
210
+ // Use setTimeout(0) to yield to event loop
211
+ await new Promise((resolve) => setTimeout(resolve, 0));
212
+ }
213
+ idGenerationLock = true;
214
+ try {
215
+ const index = await loadIndex();
216
+ const nextId = index.maxId + 1;
217
+ index.maxId = nextId;
218
+ // Synchronously update the index in memory (disk write is deferred via batching)
219
+ cachedIndex = index;
220
+ return nextId;
221
+ } finally {
222
+ idGenerationLock = false;
223
+ }
179
224
  }
180
225
 
181
226
  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,59 @@ 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
56
64
  }
57
65
  }
58
- } catch (err) {
59
- // eslint-disable-next-line no-console
60
- console.error("[logger] Failed to initialize logger:", err);
66
+ } catch {
67
+ // Skip directory initialization errors
68
+ }
69
+ }
70
+
71
+ // File-based logger for application logs (not log entries)
72
+ async function writeAppLog(message: string): Promise<void> {
73
+ try {
74
+ const logPath = getInspectorLogPath();
75
+ const logDirPath = path.dirname(logPath);
76
+ await mkdir(logDirPath, { recursive: true });
77
+ const timestamp = new Date().toISOString();
78
+ await appendFile(logPath, `[${timestamp}] ${message}\n`, "utf-8");
79
+ } catch {
80
+ // Silently fail if we can't write to log
61
81
  }
62
82
  }
63
83
 
84
+ // Application logger - writes to inspector.log
85
+ export const logger = {
86
+ debug(message: string, ...args: unknown[]): void {
87
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
88
+ void writeAppLog(`[DEBUG] ${msg}`);
89
+ },
90
+ info(message: string, ...args: unknown[]): void {
91
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
92
+ void writeAppLog(`[INFO] ${msg}`);
93
+ },
94
+ warn(message: string, ...args: unknown[]): void {
95
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
96
+ void writeAppLog(`[WARN] ${msg}`);
97
+ },
98
+ error(message: string, ...args: unknown[]): void {
99
+ const msg = args.length > 0 ? `${message} ${args.map(String).join(" ")}` : message;
100
+ void writeAppLog(`[ERROR] ${msg}`);
101
+ },
102
+ };
103
+
64
104
  // Write buffer for batching async writes
105
+ const MAX_BUFFER_SIZE = 1000; // Prevent unbounded memory growth
65
106
  let writeBuffer: string[] = [];
66
107
  let writeScheduled = false;
108
+ let isFlushing = false; // Prevent concurrent flush operations
67
109
 
68
110
  async function flushWriteBuffer(): Promise<void> {
69
111
  if (writeBuffer.length === 0) return;
112
+ if (isFlushing) return; // Skip if another flush is in progress
70
113
 
114
+ isFlushing = true;
71
115
  const toWrite = writeBuffer.join("");
72
116
  writeBuffer = [];
73
117
 
@@ -75,9 +119,10 @@ async function flushWriteBuffer(): Promise<void> {
75
119
  const filePath = getLogFilePath();
76
120
  await mkdir(path.dirname(filePath), { recursive: true });
77
121
  await appendFile(filePath, toWrite, "utf-8");
78
- } catch (err) {
79
- // eslint-disable-next-line no-console
80
- console.error("[logger] Failed to write log entries:", err);
122
+ } catch {
123
+ // Silently fail write buffer errors
124
+ } finally {
125
+ isFlushing = false;
81
126
  }
82
127
  }
83
128
 
@@ -95,5 +140,10 @@ function scheduleFlush(): void {
95
140
  export function appendLogEntry(entry: Record<string, unknown>): void {
96
141
  const line = JSON.stringify(entry) + "\n";
97
142
  writeBuffer.push(line);
98
- scheduleFlush();
143
+ // Flush immediately if buffer exceeds max size to prevent OOM
144
+ if (writeBuffer.length >= MAX_BUFFER_SIZE) {
145
+ void flushWriteBuffer();
146
+ } else {
147
+ scheduleFlush();
148
+ }
99
149
  }
@@ -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 };
@@ -1,9 +1,9 @@
1
1
  import { z } from "zod";
2
- import { readFileSync, existsSync } from "node:fs";
3
- import { createReadStream } from "node:fs";
2
+ import { readFileSync, existsSync, createReadStream } from "node:fs";
3
+ import { readdir as readdirCallback } from "node:fs/promises";
4
4
  import { createInterface } from "node:readline";
5
5
  import { join } from "node:path";
6
- import { appendLogEntry, resolveLogDir } from "./logger";
6
+ import { appendLogEntry, resolveLogDir, logger } from "./logger";
7
7
  import {
8
8
  addToIndex,
9
9
  findInIndex,
@@ -258,16 +258,15 @@ export async function getLogById(id: number): Promise<CapturedLog | null> {
258
258
  // Fallback: return the last matching entry (incomplete is better than nothing)
259
259
  if (lastMatch !== null) return lastMatch;
260
260
  } catch (err) {
261
- // eslint-disable-next-line no-console
262
- console.error("[store] Failed to read log from disk:", err);
261
+ logger.error("[store] Failed to read log from disk:", String(err));
263
262
  }
264
263
 
265
264
  return null;
266
265
  }
267
266
 
268
267
  export function getFilteredLogs(sessionId?: string, model?: string): CapturedLog[] {
269
- // Get all logs from memory cache sorted by ID
270
- const cachedLogs = Array.from(memoryCache.values()).sort((a, b) => a.id - b.id);
268
+ // Cache maintains insertion order (sorted by ID since logs are added in ID order)
269
+ const cachedLogs = Array.from(memoryCache.values());
271
270
 
272
271
  return cachedLogs.filter((l) => {
273
272
  if (sessionId !== undefined && l.sessionId !== sessionId) return false;
@@ -293,20 +292,32 @@ export function getModels(): string[] {
293
292
  }
294
293
 
295
294
  /**
296
- * Load recent completed log entries from today's log file into the memory cache.
295
+ * Load recent completed log entries from all log files into the memory cache.
297
296
  * Called on server startup so the frontend shows existing logs.
298
297
  */
299
298
  export async function loadLogsIntoMemory(): Promise<void> {
300
299
  const logDir = resolveLogDir();
301
300
  if (!existsSync(logDir)) return;
302
301
 
303
- const today = getCurrentLogFile();
304
- const filePath = join(logDir, today);
305
- if (!existsSync(filePath)) return;
306
-
307
302
  // Keep track of IDs seen so we only load the completed entry for each
308
303
  const seenComplete = new Set<number>();
309
304
 
305
+ // Get all .jsonl files sorted by name (date)
306
+ try {
307
+ const entries = await readdirCallback(logDir);
308
+ const filesWithExt = entries.filter((f: string) => f.endsWith(".jsonl")).sort();
309
+ for (const file of filesWithExt) {
310
+ const filePath = join(logDir, file);
311
+ await loadLogFile(filePath, seenComplete);
312
+ }
313
+ } catch (err) {
314
+ logger.error("[store] Failed to read log directory:", String(err));
315
+ }
316
+ }
317
+
318
+ async function loadLogFile(filePath: string, seenComplete: Set<number>): Promise<void> {
319
+ if (!existsSync(filePath)) return;
320
+
310
321
  try {
311
322
  const fileStream = createInterface({
312
323
  input: createReadStream(filePath),
@@ -348,8 +359,7 @@ export async function loadLogsIntoMemory(): Promise<void> {
348
359
  }
349
360
  }
350
361
  } catch (err) {
351
- // eslint-disable-next-line no-console
352
- console.error("[store] Failed to load logs into memory:", err);
362
+ logger.error("[store] Failed to load logs into memory:", String(err));
353
363
  }
354
364
  }
355
365
 
@@ -11,6 +11,7 @@ const ProviderUpdateSchema = z.object({
11
11
  authHeader: z.enum(["bearer", "x-api-key"]).optional(),
12
12
  anthropicBaseUrl: z.string().optional(),
13
13
  openaiBaseUrl: z.string().optional(),
14
+ apiDocsUrl: z.string().optional(),
14
15
  });
15
16
 
16
17
  export const Route = createFileRoute("/api/providers/$providerId")({
@@ -9,6 +9,7 @@ const ProviderInputSchema = z.object({
9
9
  baseUrl: z.string().min(1, "Base URL is required"),
10
10
  model: z.string().min(1, "Model is required"),
11
11
  authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
12
+ apiDocsUrl: z.string().optional(),
12
13
  });
13
14
 
14
15
  export const Route = createFileRoute("/api/providers")({
@@ -29,6 +30,7 @@ export const Route = createFileRoute("/api/providers")({
29
30
  parsed.data.baseUrl,
30
31
  parsed.data.model,
31
32
  parsed.data.authHeader,
33
+ parsed.data.apiDocsUrl,
32
34
  );
33
35
  return Response.json(newProvider, { status: 201 });
34
36
  },