@tonyclaw/agent-inspector 2.0.17 → 2.0.19

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 (73) hide show
  1. package/.output/cli.js +149 -40
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DDg7Cs4N.js → CompareDrawer-D-90PIPw.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-CxRDWacg.js +114 -0
  5. package/.output/public/assets/ReplayDialog-DzlLYoTO.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-DR8OICOJ.js → RequestAnatomy-0hbyf6oa.js} +1 -1
  7. package/.output/public/assets/ResponseView-CKe0tNqr.js +1 -0
  8. package/.output/public/assets/StreamingChunkSequence-enhfdeNh.js +1 -0
  9. package/.output/public/assets/_sessionId-CJ-SusES.js +1 -0
  10. package/.output/public/assets/index-BbFsmQRC.js +1 -0
  11. package/.output/public/assets/index-BfBgrkgU.css +1 -0
  12. package/.output/public/assets/{main-bwZlEXw2.js → main-o9-hg8g1.js} +2 -2
  13. package/.output/server/_libs/lucide-react.mjs +141 -118
  14. package/.output/server/_libs/tanstack__react-virtual.mjs +13 -2
  15. package/.output/server/_libs/tanstack__virtual-core.mjs +24 -1
  16. package/.output/server/{_sessionId-BEuJJhqT.mjs → _sessionId-F8lm6PIb.mjs} +3 -2
  17. package/.output/server/_ssr/{CompareDrawer-yTO93GMz.mjs → CompareDrawer-Cf1yfwfd.mjs} +4 -3
  18. package/.output/server/_ssr/{ProxyViewerContainer-C8qCkHGB.mjs → ProxyViewerContainer-Bmu9867L.mjs} +349 -74
  19. package/.output/server/_ssr/{ReplayDialog-DRF9PG7Z.mjs → ReplayDialog-DxZfLBOe.mjs} +199 -17
  20. package/.output/server/_ssr/{RequestAnatomy-DBcuV_jV.mjs → RequestAnatomy-CbdERGZd.mjs} +4 -3
  21. package/.output/server/_ssr/{ResponseView-JJm78HZT.mjs → ResponseView-CvLd6qxI.mjs} +4 -3
  22. package/.output/server/_ssr/{StreamingChunkSequence-DyGKjBYx.mjs → StreamingChunkSequence-B_lkRB6o.mjs} +4 -3
  23. package/.output/server/_ssr/{index-T7JG28t6.mjs → index-ejrSItfz.mjs} +3 -2
  24. package/.output/server/_ssr/index.mjs +2 -2
  25. package/.output/server/_ssr/{router-BJob8RN3.mjs → router-SbCnbDc2.mjs} +896 -240
  26. package/.output/server/{_tanstack-start-manifest_v-CuiFi3XC.mjs → _tanstack-start-manifest_v-yx8ftqWz.mjs} +1 -1
  27. package/.output/server/index.mjs +60 -60
  28. package/README.md +26 -109
  29. package/package.json +2 -1
  30. package/src/cli/doctor.ts +160 -8
  31. package/src/cli/templates/skill-onboard.ts +2 -20
  32. package/src/components/OnboardingBanner.tsx +13 -13
  33. package/src/components/ProxyViewer.tsx +55 -38
  34. package/src/components/ProxyViewerContainer.tsx +5 -0
  35. package/src/components/providers/SettingsDialog.tsx +113 -2
  36. package/src/components/proxy-viewer/ConversationGroup.tsx +20 -3
  37. package/src/components/proxy-viewer/ConversationGroupList.tsx +144 -0
  38. package/src/components/proxy-viewer/ConversationHeader.tsx +0 -2
  39. package/src/components/proxy-viewer/ReplayDialog.tsx +200 -13
  40. package/src/components/proxy-viewer/index.ts +1 -1
  41. package/src/knowledge/openclawClient.ts +0 -8
  42. package/src/knowledge/types.ts +0 -2
  43. package/src/lib/export-logs.ts +72 -5
  44. package/src/lib/providerTestContract.ts +0 -1
  45. package/src/lib/useProviders.ts +1 -1
  46. package/src/mcp/server.ts +14 -6
  47. package/src/mcp/toolHandlers.ts +21 -4
  48. package/src/proxy/chunkStorage.ts +2 -4
  49. package/src/proxy/dataDir.ts +37 -15
  50. package/src/proxy/formats/anthropic/index.ts +0 -1
  51. package/src/proxy/formats/anthropic/schemas.ts +0 -2
  52. package/src/proxy/handler.ts +41 -29
  53. package/src/proxy/logFinalizer.ts +93 -11
  54. package/src/proxy/logger.ts +35 -10
  55. package/src/proxy/providers.ts +12 -5
  56. package/src/proxy/rawStreamCapture.ts +85 -0
  57. package/src/proxy/schemas.ts +1 -51
  58. package/src/proxy/sessionRuntime.ts +0 -9
  59. package/src/proxy/socketTracker.ts +15 -8
  60. package/src/proxy/store.ts +414 -13
  61. package/src/routes/api/logs.$id.replay.ts +66 -1
  62. package/src/routes/api/logs.stream.ts +11 -3
  63. package/src/routes/api/logs.ts +51 -17
  64. package/src/routes/api/providers.$providerId.ts +10 -3
  65. package/src/routes/api/providers.ts +6 -3
  66. package/.output/public/assets/ProxyViewerContainer-DbbK1y7N.js +0 -114
  67. package/.output/public/assets/ReplayDialog-C1zWNkoz.js +0 -1
  68. package/.output/public/assets/ResponseView-Dh9iSj6h.js +0 -1
  69. package/.output/public/assets/StreamingChunkSequence-B_xElyH3.js +0 -1
  70. package/.output/public/assets/_sessionId-gv1a_NNu.js +0 -1
  71. package/.output/public/assets/index-D_nZj9Vt.css +0 -1
  72. package/.output/public/assets/index-u-LwDaeH.js +0 -1
  73. package/src/components/ui/json-expansion-button.tsx +0 -56
@@ -1,8 +1,8 @@
1
- import { existsSync } from "node:fs";
1
+ import { cpSync, existsSync, rmSync } from "node:fs";
2
2
  import { isAbsolute, join } from "node:path";
3
3
 
4
4
  const CURRENT_DATA_DIR_NAME = ".agent-inspector";
5
- const LEGACY_DATA_DIR_NAME = ".llm-inspector";
5
+ const LEGACY_DATA_DIR_NAMES = [".llm-inspector", ".llm-agent"];
6
6
 
7
7
  /**
8
8
  * Compute the directory where agent-inspector stores its on-disk state
@@ -10,14 +10,12 @@ const LEGACY_DATA_DIR_NAME = ".llm-inspector";
10
10
  * so every store agrees on the same path.
11
11
  *
12
12
  * Resolution order (first match wins):
13
- * 1. `AGENT_INSPECTOR_DATA_DIR` env var absolute path used as-is,
13
+ * 1. `AGENT_INSPECTOR_DATA_DIR` env var - absolute path used as-is,
14
14
  * relative path joined to the OS-specific base below.
15
- * 2. `AGENT_INSPECTOR_CONFIG_DIR` env var kept for backward compat
15
+ * 2. `AGENT_INSPECTOR_CONFIG_DIR` env var - kept for backward compat
16
16
  * with the runtime config; treated as a directory *name* (relative
17
17
  * to the OS-specific base) when not absolute.
18
18
  * 3. OS-specific base + `.agent-inspector`.
19
- * 4. Backward compatibility: if `.agent-inspector` has no known state
20
- * files but `.llm-inspector` does, reuse `.llm-inspector`.
21
19
  *
22
20
  * Windows base is `%USERPROFILE%` (falling back to `%APPDATA%`, then `C:\`).
23
21
  * macOS / Linux base is `$HOME` (falling back to `/tmp`).
@@ -26,6 +24,7 @@ const LEGACY_DATA_DIR_NAME = ".llm-inspector";
26
24
  * directory after this module has been imported.
27
25
  */
28
26
  export function getDataDir(): string {
27
+ migrateLegacyDataDirs();
29
28
  return resolveDataDir(existsSync);
30
29
  }
31
30
 
@@ -41,15 +40,21 @@ function hasDataDirState(pathExists: PathExists, dir: string): boolean {
41
40
  );
42
41
  }
43
42
 
44
- export function resolveDataDir(
45
- pathExists: PathExists,
43
+ function defaultBaseDir(
46
44
  env: DataDirEnv = process.env,
47
45
  platform: typeof process.platform = process.platform,
48
46
  ): string {
49
- const isWindows = platform === "win32";
50
- const base = isWindows
47
+ return platform === "win32"
51
48
  ? (env["USERPROFILE"] ?? env["APPDATA"] ?? "C:\\")
52
49
  : (env["HOME"] ?? "/tmp");
50
+ }
51
+
52
+ export function resolveDataDir(
53
+ _pathExists: PathExists,
54
+ env: DataDirEnv = process.env,
55
+ platform: typeof process.platform = process.platform,
56
+ ): string {
57
+ const base = defaultBaseDir(env, platform);
53
58
 
54
59
  const dirEnv = env["AGENT_INSPECTOR_DATA_DIR"];
55
60
  if (dirEnv !== undefined && dirEnv !== "") {
@@ -64,11 +69,7 @@ export function resolveDataDir(
64
69
  return isAbsolute(legacyDirEnv) ? legacyDirEnv : join(base, legacyDirEnv);
65
70
  }
66
71
 
67
- const currentDir = join(base, CURRENT_DATA_DIR_NAME);
68
- const legacyDir = join(base, LEGACY_DATA_DIR_NAME);
69
- return !hasDataDirState(pathExists, currentDir) && hasDataDirState(pathExists, legacyDir)
70
- ? legacyDir
71
- : currentDir;
72
+ return join(base, CURRENT_DATA_DIR_NAME);
72
73
  }
73
74
 
74
75
  /**
@@ -84,3 +85,24 @@ export function hasExplicitDataDir(): boolean {
84
85
  process.env["AGENT_INSPECTOR_CONFIG_DIR"] !== "")
85
86
  );
86
87
  }
88
+
89
+ export function migrateLegacyDataDirs(): void {
90
+ if (hasExplicitDataDir()) return;
91
+
92
+ const base = defaultBaseDir();
93
+ const currentDir = join(base, CURRENT_DATA_DIR_NAME);
94
+ if (hasDataDirState(existsSync, currentDir)) return;
95
+
96
+ for (const legacyName of LEGACY_DATA_DIR_NAMES) {
97
+ const legacyDir = join(base, legacyName);
98
+ if (!existsSync(legacyDir)) continue;
99
+
100
+ try {
101
+ cpSync(legacyDir, currentDir, { recursive: true, force: true });
102
+ rmSync(legacyDir, { recursive: true, force: true });
103
+ } catch {
104
+ // Best-effort migration: startup must continue even when the old
105
+ // directory is locked or partially unreadable. The next call can retry.
106
+ }
107
+ }
108
+ }
@@ -6,7 +6,6 @@ export {
6
6
  AnthropicRequestSchema,
7
7
  AnthropicResponseSchema,
8
8
  SseEventSchema,
9
- type AnthropicRequest,
10
9
  type AnthropicResponse,
11
10
  type ResponseContentBlockType,
12
11
  } from "./schemas";
@@ -226,12 +226,10 @@ export const SseEventSchema = z.discriminatedUnion("type", [
226
226
  SseErrorEvent,
227
227
  ]);
228
228
 
229
- export type AnthropicRequest = z.infer<typeof AnthropicRequestSchema>;
230
229
  export type AnthropicResponse = z.infer<typeof AnthropicResponseSchema>;
231
230
  export type ResponseContentBlockType = z.infer<typeof ResponseContentBlock>;
232
231
 
233
232
  // Backward-compatible aliases (originally named Inspector*)
234
233
  export const InspectorRequestSchema = AnthropicRequestSchema;
235
234
  export const InspectorResponseSchema = AnthropicResponseSchema;
236
- export type InspectorRequest = AnthropicRequest;
237
235
  export type InspectorResponse = AnthropicResponse;
@@ -23,6 +23,7 @@ import { stripClaudeCodeBillingHeader } from "./claudeCodeStrip";
23
23
  import { stripOpenAIOrphanToolMessages } from "./openaiOrphanToolStrip";
24
24
  import { buildFileLogEntry, type FinalizeLogJob } from "./logFinalizer";
25
25
  import { enqueueFinalizeLogJob } from "./sessionRuntime";
26
+ import { RawStreamCapture } from "./rawStreamCapture";
26
27
  import {
27
28
  buildUpstreamUrl,
28
29
  describeApiRoute,
@@ -138,27 +139,50 @@ function handleStreamingResponse(
138
139
  log.streaming = true;
139
140
  log.responseStatus = upstreamRes.status;
140
141
 
141
- const chunks: string[] = [];
142
+ const capture = new RawStreamCapture();
142
143
  const decoder = new TextDecoder();
144
+ let finalizationScheduled = false;
145
+
146
+ const scheduleStreamingFinalization = (type: "streaming" | "stream-abort"): void => {
147
+ if (finalizationScheduled) return;
148
+ finalizationScheduled = true;
149
+
150
+ const elapsedMs = Date.now() - startTime;
151
+ const rawStream = capture.snapshot();
152
+ switch (type) {
153
+ case "streaming":
154
+ scheduleLogFinalization({
155
+ type: "streaming",
156
+ log,
157
+ upstreamUrl,
158
+ elapsedMs,
159
+ responseStatus: upstreamRes.status,
160
+ rawStream,
161
+ collectStreamingChunks,
162
+ });
163
+ return;
164
+ case "stream-abort":
165
+ scheduleLogFinalization({
166
+ type: "stream-abort",
167
+ log,
168
+ upstreamUrl,
169
+ elapsedMs,
170
+ rawStream,
171
+ hasChunks: capture.hasChunks,
172
+ collectStreamingChunks,
173
+ });
174
+ return;
175
+ }
176
+ };
143
177
 
144
178
  const transform = new TransformStream<Uint8Array, Uint8Array>({
145
- transform(chunk, controller) {
179
+ async transform(chunk, controller) {
146
180
  controller.enqueue(chunk);
147
- chunks.push(decoder.decode(chunk, { stream: true }));
181
+ await capture.append(decoder.decode(chunk, { stream: true }));
148
182
  },
149
- flush() {
150
- const full = chunks.join("");
151
- const elapsedMs = Date.now() - startTime;
152
-
153
- scheduleLogFinalization({
154
- type: "streaming",
155
- log,
156
- upstreamUrl,
157
- elapsedMs,
158
- responseStatus: upstreamRes.status,
159
- rawStream: full,
160
- collectStreamingChunks,
161
- });
183
+ async flush() {
184
+ await capture.append(decoder.decode());
185
+ scheduleStreamingFinalization("streaming");
162
186
  },
163
187
  });
164
188
 
@@ -171,19 +195,7 @@ function handleStreamingResponse(
171
195
  req.signal?.addEventListener("abort", () => {
172
196
  if (log.responseText === null) {
173
197
  logger.info(`[handler] Streaming client aborted: ${log.method} ${log.path}`);
174
- const elapsedMs = Date.now() - startTime;
175
- const full = chunks.join("");
176
- const hasChunks = chunks.length > 0;
177
-
178
- scheduleLogFinalization({
179
- type: "stream-abort",
180
- log,
181
- upstreamUrl,
182
- elapsedMs,
183
- rawStream: full,
184
- hasChunks,
185
- collectStreamingChunks,
186
- });
198
+ scheduleStreamingFinalization("stream-abort");
187
199
  }
188
200
  });
189
201
 
@@ -1,7 +1,9 @@
1
+ import { readFileSync, rmSync } from "node:fs";
1
2
  import { Worker } from "node:worker_threads";
2
3
  import { writeChunks } from "./chunkStorage";
3
4
  import { formatForPath } from "./formats";
4
5
  import { appendLogEntry, logger } from "./logger";
6
+ import type { RawStreamSource } from "./rawStreamCapture";
5
7
  import type { CapturedLog } from "./schemas";
6
8
  import { getSessionProcess, isSessionProcessAvailable } from "./sessionProcess";
7
9
  import { finalizeLogUpdate } from "./store";
@@ -21,13 +23,13 @@ export type FinalizeNonStreamingLogJob = BaseFinalizeLogJob & {
21
23
  export type FinalizeStreamingLogJob = BaseFinalizeLogJob & {
22
24
  type: "streaming";
23
25
  responseStatus: number;
24
- rawStream: string;
26
+ rawStream: string | RawStreamSource;
25
27
  collectStreamingChunks: boolean;
26
28
  };
27
29
 
28
30
  export type FinalizeStreamAbortLogJob = BaseFinalizeLogJob & {
29
31
  type: "stream-abort";
30
- rawStream: string;
32
+ rawStream: string | RawStreamSource;
31
33
  hasChunks: boolean;
32
34
  collectStreamingChunks: boolean;
33
35
  };
@@ -41,6 +43,7 @@ export type FinalizeLogResult = {
41
43
  log: CapturedLog;
42
44
  upstreamUrl: string;
43
45
  error: string | null;
46
+ cleanupRawStreamPath?: string | null;
44
47
  };
45
48
 
46
49
  export function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string, unknown> {
@@ -98,6 +101,7 @@ function finalizeWithError(
98
101
  fallbackStatus: number,
99
102
  fallbackResponseText: string,
100
103
  err: unknown,
104
+ cleanupRawStreamPath: string | null = null,
101
105
  ): FinalizeLogResult {
102
106
  const message = errorMessage(err);
103
107
  logger.error(`[logFinalizer] Failed to finalize log #${log.id}:`, message);
@@ -105,7 +109,7 @@ function finalizeWithError(
105
109
  log.responseText = log.responseText ?? fallbackResponseText;
106
110
  log.elapsedMs = job.elapsedMs;
107
111
  log.error = message;
108
- return { log, upstreamUrl: job.upstreamUrl, error: message };
112
+ return { log, upstreamUrl: job.upstreamUrl, error: message, cleanupRawStreamPath };
109
113
  }
110
114
 
111
115
  function finalizeNonStreaming(
@@ -134,40 +138,100 @@ function finalizeNonStreaming(
134
138
  }
135
139
  }
136
140
 
141
+ function cleanupPathForRawStream(rawStream: string | RawStreamSource): string | null {
142
+ if (typeof rawStream === "string") return null;
143
+ switch (rawStream.type) {
144
+ case "memory":
145
+ return null;
146
+ case "file":
147
+ return rawStream.path;
148
+ }
149
+ }
150
+
151
+ function readRawStream(rawStream: string | RawStreamSource): {
152
+ rawStreamText: string;
153
+ cleanupRawStreamPath: string | null;
154
+ } {
155
+ if (typeof rawStream === "string") {
156
+ return { rawStreamText: rawStream, cleanupRawStreamPath: null };
157
+ }
158
+
159
+ switch (rawStream.type) {
160
+ case "memory":
161
+ return { rawStreamText: rawStream.rawStream, cleanupRawStreamPath: null };
162
+ case "file":
163
+ return {
164
+ rawStreamText: readFileSync(rawStream.path, "utf-8"),
165
+ cleanupRawStreamPath: rawStream.path,
166
+ };
167
+ }
168
+ }
169
+
137
170
  function finalizeStreaming(job: FinalizeStreamingLogJob, log: CapturedLog): FinalizeLogResult {
138
171
  const formatHandler = formatForPath(log.path);
139
172
  if (formatHandler === null) {
140
- return finalizeWithError(job, log, job.responseStatus, job.rawStream, "Unsupported format");
173
+ return finalizeWithError(
174
+ job,
175
+ log,
176
+ job.responseStatus,
177
+ "Streaming response unavailable",
178
+ "Unsupported format",
179
+ cleanupPathForRawStream(job.rawStream),
180
+ );
141
181
  }
142
182
 
183
+ let rawStreamText = "";
184
+ let cleanupRawStreamPath: string | null = null;
143
185
  try {
186
+ const raw = readRawStream(job.rawStream);
187
+ rawStreamText = raw.rawStreamText;
188
+ cleanupRawStreamPath = raw.cleanupRawStreamPath;
144
189
  log.elapsedMs = job.elapsedMs;
145
190
  log.responseStatus = job.responseStatus;
146
191
  log.responseText = formatHandler.extractStream(
147
- job.rawStream,
192
+ rawStreamText,
148
193
  log,
149
194
  log.model ?? undefined,
150
195
  job.collectStreamingChunks,
151
196
  );
152
197
  persistStreamingChunks(log);
153
198
 
154
- return { log, upstreamUrl: job.upstreamUrl, error: null };
199
+ return { log, upstreamUrl: job.upstreamUrl, error: null, cleanupRawStreamPath };
155
200
  } catch (err) {
156
- return finalizeWithError(job, log, job.responseStatus, job.rawStream, err);
201
+ return finalizeWithError(
202
+ job,
203
+ log,
204
+ job.responseStatus,
205
+ rawStreamText === "" ? "Streaming response unavailable" : rawStreamText,
206
+ err,
207
+ cleanupRawStreamPath ?? cleanupPathForRawStream(job.rawStream),
208
+ );
157
209
  }
158
210
  }
159
211
 
160
212
  function finalizeStreamAbort(job: FinalizeStreamAbortLogJob, log: CapturedLog): FinalizeLogResult {
161
213
  const formatHandler = formatForPath(log.path);
162
214
  if (formatHandler === null && job.hasChunks) {
163
- return finalizeWithError(job, log, 499, "Client aborted", "Unsupported format");
215
+ return finalizeWithError(
216
+ job,
217
+ log,
218
+ 499,
219
+ "Client aborted",
220
+ "Unsupported format",
221
+ cleanupPathForRawStream(job.rawStream),
222
+ );
164
223
  }
165
224
 
225
+ let rawStreamText = "";
226
+ let cleanupRawStreamPath: string | null = null;
166
227
  try {
167
228
  log.elapsedMs = job.elapsedMs;
168
229
  if (job.hasChunks && formatHandler !== null) {
230
+ const raw = readRawStream(job.rawStream);
231
+ rawStreamText = raw.rawStreamText;
232
+ cleanupRawStreamPath = raw.cleanupRawStreamPath;
169
233
  log.responseText = formatHandler.extractStream(
170
- job.rawStream,
234
+ rawStreamText,
171
235
  log,
172
236
  log.model ?? undefined,
173
237
  job.collectStreamingChunks,
@@ -177,9 +241,16 @@ function finalizeStreamAbort(job: FinalizeStreamAbortLogJob, log: CapturedLog):
177
241
  log.responseText = "Client aborted";
178
242
  }
179
243
 
180
- return { log, upstreamUrl: job.upstreamUrl, error: "Client aborted" };
244
+ return { log, upstreamUrl: job.upstreamUrl, error: "Client aborted", cleanupRawStreamPath };
181
245
  } catch (err) {
182
- return finalizeWithError(job, log, 499, "Client aborted", err);
246
+ return finalizeWithError(
247
+ job,
248
+ log,
249
+ 499,
250
+ rawStreamText === "" ? "Client aborted" : rawStreamText,
251
+ err,
252
+ cleanupRawStreamPath ?? cleanupPathForRawStream(job.rawStream),
253
+ );
183
254
  }
184
255
  }
185
256
 
@@ -199,6 +270,17 @@ export function buildFinalizeLogResult(job: FinalizeLogJob): FinalizeLogResult {
199
270
  export function commitFinalizeLogResult(result: FinalizeLogResult): void {
200
271
  appendLogEntry({ ...buildFileLogEntry(result.log, result.upstreamUrl), error: result.error });
201
272
  finalizeLogUpdate(result.log);
273
+ if (result.cleanupRawStreamPath !== undefined && result.cleanupRawStreamPath !== null) {
274
+ try {
275
+ rmSync(result.cleanupRawStreamPath, { force: true });
276
+ } catch (err) {
277
+ logger.warn(
278
+ "[logFinalizer] Failed to remove raw stream temp file:",
279
+ result.cleanupRawStreamPath,
280
+ errorMessage(err),
281
+ );
282
+ }
283
+ }
202
284
  }
203
285
 
204
286
  // ── Worker pool ────────────────────────────────────────────────
@@ -5,6 +5,10 @@ import { getDataDir } from "./dataDir";
5
5
 
6
6
  const RETENTION_DAYS = Number(process.env["LOG_RETENTION_DAYS"] ?? "7");
7
7
 
8
+ function writeStderrFallback(message: string, error: unknown): void {
9
+ process.stderr.write(`${message} ${String(error)}\n`);
10
+ }
11
+
8
12
  export function resolveLogDir(): string {
9
13
  const logDirEnv = process.env["LOG_DIR"];
10
14
  if (logDirEnv !== undefined && logDirEnv !== "") {
@@ -51,8 +55,7 @@ export async function initLogger(): Promise<void> {
51
55
  }
52
56
  } catch (err) {
53
57
  // Log directory initialization errors but don't fail startup
54
- // eslint-disable-next-line no-console
55
- console.error("[logger] Failed to initialize log directory:", err);
58
+ writeStderrFallback("[logger] Failed to initialize log directory:", err);
56
59
  }
57
60
  }
58
61
 
@@ -74,8 +77,7 @@ async function writeAppLog(message: string): Promise<void> {
74
77
  await appendFile(logPath, `[${timestamp}] ${message}\n`, "utf-8");
75
78
  } catch (err) {
76
79
  // Log to stderr since file logging failed
77
- // eslint-disable-next-line no-console
78
- console.error(`[logger] Failed to write to ${logPath}:`, err);
80
+ writeStderrFallback(`[logger] Failed to write to ${logPath}:`, err);
79
81
  }
80
82
  }
81
83
 
@@ -103,6 +105,22 @@ export const logger = {
103
105
  const MAX_BUFFER_SIZE = 1000;
104
106
  let writeBuffer: string[] = [];
105
107
  let writeQueue: Promise<void> = Promise.resolve();
108
+ let logWriteLock: Promise<void> = Promise.resolve();
109
+
110
+ export async function runWithLogWriteLock<T>(fn: () => Promise<T>): Promise<T> {
111
+ const previousLock = logWriteLock;
112
+ let releaseLock = (): void => {};
113
+ logWriteLock = new Promise<void>((resolve) => {
114
+ releaseLock = resolve;
115
+ });
116
+
117
+ await previousLock;
118
+ try {
119
+ return await fn();
120
+ } finally {
121
+ releaseLock();
122
+ }
123
+ }
106
124
 
107
125
  function drainBuffer(): string {
108
126
  const toWrite = writeBuffer.join("");
@@ -110,7 +128,7 @@ function drainBuffer(): string {
110
128
  return toWrite;
111
129
  }
112
130
 
113
- async function flushWriteBuffer(): Promise<void> {
131
+ async function flushWriteBufferUnlocked(): Promise<void> {
114
132
  let toWrite: string;
115
133
  {
116
134
  toWrite = drainBuffer();
@@ -124,8 +142,7 @@ async function flushWriteBuffer(): Promise<void> {
124
142
  } catch (err) {
125
143
  // On failure, re-queue the data so it is not lost
126
144
  writeBuffer.unshift(toWrite);
127
- // eslint-disable-next-line no-console
128
- console.error("[logger] Failed to flush write buffer, re-queued:", err);
145
+ writeStderrFallback("[logger] Failed to flush write buffer, re-queued:", err);
129
146
  }
130
147
  }
131
148
 
@@ -133,9 +150,9 @@ export function appendLogEntry(entry: Record<string, unknown>): void {
133
150
  const line = JSON.stringify(entry) + "\n";
134
151
  writeBuffer.push(line);
135
152
  if (writeBuffer.length >= MAX_BUFFER_SIZE) {
136
- writeQueue = writeQueue.then(() => flushWriteBuffer());
153
+ writeQueue = writeQueue.then(() => runWithLogWriteLock(flushWriteBufferUnlocked));
137
154
  } else if (writeBuffer.length === 1) {
138
- writeQueue = writeQueue.then(() => flushWriteBuffer());
155
+ writeQueue = writeQueue.then(() => runWithLogWriteLock(flushWriteBufferUnlocked));
139
156
  }
140
157
  }
141
158
 
@@ -146,10 +163,18 @@ export function appendLogEntry(entry: Record<string, unknown>): void {
146
163
  export async function flushLogBuffer(): Promise<void> {
147
164
  await writeQueue;
148
165
  if (writeBuffer.length > 0) {
149
- await flushWriteBuffer();
166
+ await runWithLogWriteLock(flushWriteBufferUnlocked);
150
167
  }
151
168
  }
152
169
 
170
+ export async function runWithFlushedLogWriteLock<T>(fn: () => Promise<T>): Promise<T> {
171
+ await flushLogBuffer();
172
+ return await runWithLogWriteLock(async () => {
173
+ await flushWriteBufferUnlocked();
174
+ return await fn();
175
+ });
176
+ }
177
+
153
178
  // Ensure buffered logs are written before the process exits
154
179
  process.on("exit", () => {
155
180
  if (writeBuffer.length > 0) {
@@ -3,6 +3,7 @@ import Conf from "conf";
3
3
  import { randomUUID } from "crypto";
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
+ import { maskApiKey } from "../lib/mask";
6
7
  import { logger } from "./logger";
7
8
  import { getDataDir, hasExplicitDataDir } from "./dataDir";
8
9
  import {
@@ -380,6 +381,17 @@ export function getProvider(id: string): ProviderConfig | undefined {
380
381
  return providers.find((p) => p.id === id);
381
382
  }
382
383
 
384
+ export function redactProvider(provider: ProviderConfig): ProviderConfig {
385
+ return {
386
+ ...provider,
387
+ apiKey: maskApiKey(provider.apiKey),
388
+ };
389
+ }
390
+
391
+ export function redactProviders(providers: readonly ProviderConfig[]): ProviderConfig[] {
392
+ return providers.map(redactProvider);
393
+ }
394
+
383
395
  /**
384
396
  * Normalizes an API key by stripping "Bearer " prefix if present.
385
397
  */
@@ -630,11 +642,6 @@ export function importProviders(json: string): { imported: number; errors: strin
630
642
  return { imported, errors };
631
643
  }
632
644
 
633
- function maskApiKey(apiKey: string): string {
634
- if (apiKey.length <= 8) return "••••••••";
635
- return apiKey.slice(0, 4) + "••••••••" + apiKey.slice(-4);
636
- }
637
-
638
645
  /**
639
646
  * Converts display model name to API usage name based on provider-specific rules.
640
647
  * Default behavior: returns model as-is.
@@ -0,0 +1,85 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, appendFile, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { getDataDir } from "./dataDir";
6
+
7
+ const DEFAULT_MEMORY_LIMIT_BYTES = 1024 * 1024;
8
+
9
+ export type RawStreamSource =
10
+ | {
11
+ type: "memory";
12
+ rawStream: string;
13
+ }
14
+ | {
15
+ type: "file";
16
+ path: string;
17
+ };
18
+
19
+ function resolveMemoryLimitBytes(): number {
20
+ const raw = process.env["AGENT_INSPECTOR_STREAM_MEMORY_LIMIT_BYTES"];
21
+ if (raw === undefined || raw === "") return DEFAULT_MEMORY_LIMIT_BYTES;
22
+ const parsed = Number(raw);
23
+ if (!Number.isInteger(parsed) || parsed < 0) return DEFAULT_MEMORY_LIMIT_BYTES;
24
+ return parsed;
25
+ }
26
+
27
+ function getRawStreamDir(): string {
28
+ return join(getDataDir(), "raw-streams");
29
+ }
30
+
31
+ function createRawStreamPath(): string {
32
+ return join(getRawStreamDir(), `${Date.now()}-${randomUUID()}.stream.tmp`);
33
+ }
34
+
35
+ export class RawStreamCapture {
36
+ private readonly memoryLimitBytes: number;
37
+ private chunks: string[] = [];
38
+ private byteLength = 0;
39
+ private filePath: string | null = null;
40
+ private sawChunk = false;
41
+
42
+ constructor(memoryLimitBytes: number = resolveMemoryLimitBytes()) {
43
+ this.memoryLimitBytes = memoryLimitBytes;
44
+ }
45
+
46
+ get hasChunks(): boolean {
47
+ return this.sawChunk;
48
+ }
49
+
50
+ async append(text: string): Promise<void> {
51
+ if (text === "") return;
52
+ this.sawChunk = true;
53
+
54
+ const nextBytes = Buffer.byteLength(text, "utf-8");
55
+ if (this.filePath === null && this.byteLength + nextBytes <= this.memoryLimitBytes) {
56
+ this.chunks.push(text);
57
+ this.byteLength += nextBytes;
58
+ return;
59
+ }
60
+
61
+ if (this.filePath === null) {
62
+ await this.spillToFile();
63
+ }
64
+
65
+ if (this.filePath !== null) {
66
+ await appendFile(this.filePath, text, "utf-8");
67
+ this.byteLength += nextBytes;
68
+ }
69
+ }
70
+
71
+ snapshot(): RawStreamSource {
72
+ if (this.filePath !== null) {
73
+ return { type: "file", path: this.filePath };
74
+ }
75
+ return { type: "memory", rawStream: this.chunks.join("") };
76
+ }
77
+
78
+ private async spillToFile(): Promise<void> {
79
+ const path = createRawStreamPath();
80
+ await mkdir(getRawStreamDir(), { recursive: true });
81
+ await writeFile(path, this.chunks.join(""), "utf-8");
82
+ this.filePath = path;
83
+ this.chunks = [];
84
+ }
85
+ }