@tonyclaw/agent-inspector 3.0.7 → 3.0.8

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 (66) hide show
  1. package/.output/cli.js +245 -171
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-C0Kbsm-Q.js → CompareDrawer-Bth_Ywyt.js} +1 -1
  4. package/.output/public/assets/ProxyViewerContainer-C73EU9Jq.js +106 -0
  5. package/.output/public/assets/ReplayDialog-C2YYAuTe.js +1 -0
  6. package/.output/public/assets/{RequestAnatomy-fa0JXy5p.js → RequestAnatomy-8k9dAX2j.js} +1 -1
  7. package/.output/public/assets/ResponseView-DEL6JcXm.js +3 -0
  8. package/.output/public/assets/StreamingChunkSequence-DngmoR9j.js +1 -0
  9. package/.output/public/assets/{_sessionId-D5fYH2C0.js → _sessionId-4vk9RMZ5.js} +1 -1
  10. package/.output/public/assets/index-DBEB1rHe.css +1 -0
  11. package/.output/public/assets/{index-BEsqvywM.js → index-DINLvnm9.js} +1 -1
  12. package/.output/public/assets/index-LPIGiUMP.js +1 -0
  13. package/.output/public/assets/{json-viewer-B0QRbMSf.js → json-viewer-CH5dvS2Y.js} +1 -1
  14. package/.output/public/assets/{main-CuKZD7n0.js → main-GWkZn4xy.js} +2 -2
  15. package/.output/server/{_sessionId-DdBTK3qm.mjs → _sessionId-EgsjdkN7.mjs} +3 -3
  16. package/.output/server/_ssr/{CompareDrawer-m82cVZ73.mjs → CompareDrawer-CaM8FYuD.mjs} +2 -2
  17. package/.output/server/_ssr/{ProxyViewerContainer-7geuf78f.mjs → ProxyViewerContainer-EJIogcGd.mjs} +545 -309
  18. package/.output/server/_ssr/{ReplayDialog-B8RlWLLH.mjs → ReplayDialog-BPpJ2-q8.mjs} +58 -23
  19. package/.output/server/_ssr/{RequestAnatomy-BE7pmwpv.mjs → RequestAnatomy-DeQAWnZx.mjs} +2 -2
  20. package/.output/server/_ssr/{ResponseView-D-sDsdQr.mjs → ResponseView-Dshm8iQ7.mjs} +2 -2
  21. package/.output/server/_ssr/{StreamingChunkSequence-DU_hThJr.mjs → StreamingChunkSequence-32yIU2hn.mjs} +30 -20
  22. package/.output/server/_ssr/{index-D0sV8UbE.mjs → index-XRcx9blU.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-CaDh1zrw.mjs → json-viewer-C0aX5rqC.mjs} +2 -2
  25. package/.output/server/_ssr/{router-Zh2iZ_u0.mjs → router-BI-Mvxz9.mjs} +441 -172
  26. package/.output/server/_tanstack-start-manifest_v-jKScklL_.mjs +4 -0
  27. package/.output/server/index.mjs +71 -71
  28. package/package.json +1 -1
  29. package/src/assets/IDE/opencode.svg +1 -1
  30. package/src/assets/IDE/xiaomimimo.svg +1 -1
  31. package/src/cli/detect-tools.ts +2 -27
  32. package/src/cli/templates/codex-skill-onboard.ts +1 -1
  33. package/src/cli/templates/skill-onboard.ts +2 -3
  34. package/src/cli.ts +9 -86
  35. package/src/components/ProxyViewer.tsx +55 -8
  36. package/src/components/ProxyViewerContainer.tsx +30 -19
  37. package/src/components/clients/ClientLogo.tsx +22 -2
  38. package/src/components/ecosystem/AgentLabDialog.tsx +204 -3
  39. package/src/components/groups/GroupsDialog.tsx +28 -40
  40. package/src/components/providers/ImportWizardDialog.tsx +51 -101
  41. package/src/components/providers/ProvidersPanel.tsx +82 -68
  42. package/src/components/providers/SettingsDialog.tsx +11 -12
  43. package/src/components/proxy-viewer/AgentTraceSummary.tsx +15 -23
  44. package/src/components/proxy-viewer/ReplayDialog.tsx +65 -22
  45. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +33 -20
  46. package/src/lib/apiClient.ts +43 -10
  47. package/src/lib/ecosystemContract.ts +32 -0
  48. package/src/lib/export-logs.ts +37 -3
  49. package/src/proxy/ecosystemRegistry.ts +244 -0
  50. package/src/proxy/ecosystemTasks.ts +153 -0
  51. package/src/proxy/platformCommands.ts +212 -0
  52. package/src/proxy/socketTracker.ts +84 -143
  53. package/src/routes/api/ecosystem.packages.$packageId.help.ts +16 -0
  54. package/src/routes/api/ecosystem.packages.$packageId.install.ts +16 -0
  55. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +19 -0
  56. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +16 -0
  57. package/src/routes/api/ecosystem.packages.ts +1 -248
  58. package/src/routes/api/ecosystem.tasks.$taskId.ts +16 -0
  59. package/src/routes/api/ecosystem.tasks.ts +12 -0
  60. package/.output/public/assets/ProxyViewerContainer-p-rpYyiF.js +0 -106
  61. package/.output/public/assets/ReplayDialog-CM3dhP8v.js +0 -1
  62. package/.output/public/assets/ResponseView-Bq6sC7Ay.js +0 -3
  63. package/.output/public/assets/StreamingChunkSequence-CGOun3Df.js +0 -1
  64. package/.output/public/assets/index-C29Olfzt.js +0 -1
  65. package/.output/public/assets/index-DgANE0r3.css +0 -1
  66. package/.output/server/_tanstack-start-manifest_v-LCE-2AaF.mjs +0 -4
@@ -1,66 +1,12 @@
1
- import { type JSX, useState, useCallback, useEffect } from "react";
1
+ import { type JSX, useState, useCallback, useEffect, useRef } from "react";
2
2
  import { z } from "zod";
3
3
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
4
4
  import { Button } from "../ui/button";
5
+ import { fetchJsonWithTimeout } from "../../lib/apiClient";
6
+ import { ClientLogo } from "../clients/ClientLogo";
5
7
 
6
8
  import { Loader2, Download, AlertCircle } from "lucide-react";
7
9
 
8
- function ClaudeCodeIcon(): JSX.Element {
9
- return (
10
- <svg
11
- fill="currentColor"
12
- fillRule="evenodd"
13
- viewBox="0 0 24 24"
14
- xmlns="http://www.w3.org/2000/svg"
15
- className="size-6 shrink-0"
16
- >
17
- <title>Claude Code</title>
18
- <path
19
- clipRule="evenodd"
20
- d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z"
21
- />
22
- </svg>
23
- );
24
- }
25
-
26
- function OpenCodeIcon(): JSX.Element {
27
- return (
28
- <svg
29
- fill="currentColor"
30
- fillRule="evenodd"
31
- viewBox="0 0 24 24"
32
- xmlns="http://www.w3.org/2000/svg"
33
- className="size-6 shrink-0"
34
- >
35
- <title>OpenCode</title>
36
- <path d="M16 6H8v12h8V6zm4 16H4V2h16v20z" />
37
- </svg>
38
- );
39
- }
40
-
41
- function MiMoCodeIcon(): JSX.Element {
42
- return (
43
- <svg
44
- fill="currentColor"
45
- viewBox="0 0 24 24"
46
- xmlns="http://www.w3.org/2000/svg"
47
- className="size-6 shrink-0"
48
- >
49
- <title>MiMo Code</title>
50
- <text
51
- x="12"
52
- y="18"
53
- textAnchor="middle"
54
- fontSize="16"
55
- fontWeight="800"
56
- fontFamily="system-ui, sans-serif"
57
- >
58
- M
59
- </text>
60
- </svg>
61
- );
62
- }
63
-
64
10
  const ExternalProviderSchema = z.object({
65
11
  name: z.string(),
66
12
  apiKey: z.string(),
@@ -85,11 +31,7 @@ const ImportResponseSchema = z.object({
85
31
  errors: z.array(z.string()).optional(),
86
32
  });
87
33
 
88
- const sourceLogoMap: Record<string, () => JSX.Element> = {
89
- "claude-code": ClaudeCodeIcon,
90
- opencode: OpenCodeIcon,
91
- "mimo-code": MiMoCodeIcon,
92
- };
34
+ const PROVIDER_IMPORT_TIMEOUT_MS = 20_000;
93
35
 
94
36
  type ImportWizardDialogProps = {
95
37
  open: boolean;
@@ -110,8 +52,12 @@ export function ImportWizardDialog({
110
52
  const [importing, setImporting] = useState(false);
111
53
  const [importResult, setImportResult] = useState<string | null>(null);
112
54
  const [importError, setImportError] = useState<string | null>(null);
55
+ const scanRequestIdRef = useRef(0);
56
+ const importRequestIdRef = useRef(0);
113
57
 
114
58
  const scan = useCallback(() => {
59
+ const requestId = scanRequestIdRef.current + 1;
60
+ scanRequestIdRef.current = requestId;
115
61
  setScanning(true);
116
62
  setScanError(null);
117
63
  setWarnings([]);
@@ -120,33 +66,29 @@ export function ImportWizardDialog({
120
66
  setImportResult(null);
121
67
  setImportError(null);
122
68
 
123
- fetch("/api/providers/scan")
124
- .then((res) => {
125
- if (!res.ok) {
126
- return res.text().then((text) => {
127
- setScanError(`Scan failed (${res.status}): ${text}`);
128
- });
129
- }
130
- return res.json().then((data: unknown) => {
131
- const parsed = ScanResponseSchema.safeParse(data);
132
- if (!parsed.success) {
133
- setScanError(`Invalid response: ${parsed.error.message}`);
134
- return;
135
- }
136
- setProviders(parsed.data.providers);
137
- setWarnings(parsed.data.warnings ?? []);
138
- // Pre-select non-existing providers
139
- const indices = new Set<number>();
140
- parsed.data.providers.forEach((p, i) => {
141
- if (!p.alreadyExists) indices.add(i);
142
- });
143
- setSelected(indices);
69
+ void fetchJsonWithTimeout(
70
+ "/api/providers/scan",
71
+ ScanResponseSchema,
72
+ PROVIDER_IMPORT_TIMEOUT_MS,
73
+ undefined,
74
+ (response) => `Scan failed (${String(response.status)})`,
75
+ )
76
+ .then((data) => {
77
+ if (scanRequestIdRef.current !== requestId) return;
78
+ setProviders(data.providers);
79
+ setWarnings(data.warnings ?? []);
80
+ const indices = new Set<number>();
81
+ data.providers.forEach((provider, index) => {
82
+ if (!provider.alreadyExists) indices.add(index);
144
83
  });
84
+ setSelected(indices);
145
85
  })
146
86
  .catch((err: unknown) => {
87
+ if (scanRequestIdRef.current !== requestId) return;
147
88
  setScanError(err instanceof Error ? err.message : String(err));
148
89
  })
149
90
  .finally(() => {
91
+ if (scanRequestIdRef.current !== requestId) return;
150
92
  setScanning(false);
151
93
  });
152
94
  }, []);
@@ -158,6 +100,14 @@ export function ImportWizardDialog({
158
100
  }
159
101
  }, [open, scan]);
160
102
 
103
+ useEffect(() => {
104
+ if (open) return;
105
+ scanRequestIdRef.current += 1;
106
+ importRequestIdRef.current += 1;
107
+ setScanning(false);
108
+ setImporting(false);
109
+ }, [open]);
110
+
161
111
  const toggleProvider = useCallback((index: number) => {
162
112
  setSelected((prev) => {
163
113
  const next = new Set(prev);
@@ -174,6 +124,8 @@ export function ImportWizardDialog({
174
124
  const toImport = providers.filter((_, i) => selected.has(i));
175
125
  if (toImport.length === 0) return;
176
126
 
127
+ const requestId = importRequestIdRef.current + 1;
128
+ importRequestIdRef.current = requestId;
177
129
  setImporting(true);
178
130
  setImportError(null);
179
131
  setImportResult(null);
@@ -193,19 +145,19 @@ export function ImportWizardDialog({
193
145
  updatedAt: now,
194
146
  }));
195
147
 
196
- fetch("/api/providers/import", {
197
- method: "POST",
198
- headers: { "Content-Type": "application/json" },
199
- body: JSON.stringify({ providers: providersPayload }),
200
- })
201
- .then((res) => res.json())
202
- .then((data: unknown) => {
203
- const parsed = ImportResponseSchema.safeParse(data);
204
- if (!parsed.success) {
205
- setImportError(`Invalid response: ${parsed.error.message}`);
206
- return;
207
- }
208
- const result = parsed.data;
148
+ void fetchJsonWithTimeout(
149
+ "/api/providers/import",
150
+ ImportResponseSchema,
151
+ PROVIDER_IMPORT_TIMEOUT_MS,
152
+ {
153
+ method: "POST",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify({ providers: providersPayload }),
156
+ },
157
+ (response) => `Import failed (${String(response.status)})`,
158
+ )
159
+ .then((result) => {
160
+ if (importRequestIdRef.current !== requestId) return;
209
161
  // Mark imported providers as already existing so they can't be re-selected
210
162
  setProviders((prev) =>
211
163
  prev.map((p, i) => (selected.has(i) ? { ...p, alreadyExists: true } : p)),
@@ -224,9 +176,11 @@ export function ImportWizardDialog({
224
176
  onImportComplete();
225
177
  })
226
178
  .catch((err: unknown) => {
179
+ if (importRequestIdRef.current !== requestId) return;
227
180
  setImportError(err instanceof Error ? err.message : String(err));
228
181
  })
229
182
  .finally(() => {
183
+ if (importRequestIdRef.current !== requestId) return;
230
184
  setImporting(false);
231
185
  });
232
186
  }, [providers, selected, onImportComplete]);
@@ -286,11 +240,7 @@ export function ImportWizardDialog({
286
240
  )}
287
241
  <div className="flex-1 min-w-0">
288
242
  <div className="flex items-center justify-between">
289
- {(() => {
290
- const LogoComponent = sourceLogoMap[p.sourceTool];
291
- if (LogoComponent === undefined) return null;
292
- return <LogoComponent />;
293
- })()}
243
+ <ClientLogo client={p.sourceTool} className="size-7 rounded-[8px]" />
294
244
  {p.alreadyExists && (
295
245
  <span className="text-[10px] text-muted-foreground">Already added</span>
296
246
  )}
@@ -7,7 +7,13 @@ import { ImportWizardDialog } from "./ImportWizardDialog";
7
7
  import { ConfirmDialog } from "../ui/confirm-dialog";
8
8
  import { ProviderCard } from "./ProviderCard";
9
9
  import { ProviderForm } from "./ProviderForm";
10
- import { parseJsonResponse, readApiError } from "../../lib/apiClient";
10
+ import {
11
+ ApiTimeoutError,
12
+ fetchJsonWithTimeout,
13
+ fetchWithTimeout,
14
+ parseJsonResponse,
15
+ readApiError,
16
+ } from "../../lib/apiClient";
11
17
  import { copyTextToClipboard } from "../../lib/clipboard";
12
18
  import {
13
19
  ProviderConfigSchema,
@@ -44,6 +50,13 @@ const ModelMetadataRefreshResponseSchema = z.object({
44
50
  });
45
51
 
46
52
  const NETWORK_ERROR_MESSAGE = "Network error: could not reach the server. Is the proxy running?";
53
+ const CONFIG_PATH_FETCH_TIMEOUT_MS = 5_000;
54
+ const PROVIDER_TEST_LOG_TIMEOUT_MS = 5_000;
55
+ const PROVIDER_WRITE_TIMEOUT_MS = 15_000;
56
+ const PROVIDER_DELETE_TIMEOUT_MS = 10_000;
57
+ const PROVIDER_EXPORT_TIMEOUT_MS = 20_000;
58
+ const PROVIDER_IMPORT_TIMEOUT_MS = 20_000;
59
+ const PROVIDER_METADATA_TIMEOUT_MS = 30_000;
47
60
 
48
61
  type ProviderFormData = {
49
62
  name: string;
@@ -88,7 +101,7 @@ async function persistProviderTestLog(providerId: string, results: TestResults):
88
101
  return;
89
102
  }
90
103
  try {
91
- await fetch(`/api/providers/${providerId}/test/log`, {
104
+ await fetchWithTimeout(`/api/providers/${providerId}/test/log`, PROVIDER_TEST_LOG_TIMEOUT_MS, {
92
105
  method: "POST",
93
106
  headers: { "Content-Type": "application/json" },
94
107
  body: JSON.stringify(results),
@@ -176,11 +189,12 @@ export function ProvidersPanel({
176
189
  useEffect(() => {
177
190
  void (async () => {
178
191
  try {
179
- const res = await fetch("/api/config/paths");
180
- if (res.ok) {
181
- const data = await parseJsonResponse(res, ConfigPathsResponseSchema);
182
- setConfigPath(data.providerConfig);
183
- }
192
+ const data = await fetchJsonWithTimeout(
193
+ "/api/config/paths",
194
+ ConfigPathsResponseSchema,
195
+ CONFIG_PATH_FETCH_TIMEOUT_MS,
196
+ );
197
+ setConfigPath(data.providerConfig);
184
198
  } catch {
185
199
  // Ignore
186
200
  }
@@ -270,34 +284,33 @@ export function ProvidersPanel({
270
284
  }, 1000);
271
285
 
272
286
  try {
273
- const res = await fetch(`/api/providers/${providerId}/test`, {
274
- method: "POST",
275
- signal: controller.signal,
276
- });
277
- if (res.ok) {
278
- const results = await parseJsonResponse(res, ProviderTestResultsSchema);
279
- updateTestResults(providerId, results);
280
- await persistProviderTestLog(providerId, results);
281
- } else {
282
- updateTestResults(
283
- providerId,
284
- createFailedProviderTestResults(
285
- `HTTP ${res.status}: ${res.statusText}`,
286
- "server_error",
287
- ),
288
- );
289
- }
287
+ const results = await fetchJsonWithTimeout(
288
+ `/api/providers/${providerId}/test`,
289
+ ProviderTestResultsSchema,
290
+ timeoutSeconds * 1000 + 1000,
291
+ {
292
+ method: "POST",
293
+ signal: controller.signal,
294
+ },
295
+ (response) => `HTTP ${response.status}: ${response.statusText}`,
296
+ );
297
+ updateTestResults(providerId, results);
298
+ await persistProviderTestLog(providerId, results);
290
299
  } catch (err) {
291
300
  // Check if this was an abort (timeout)
292
301
  const isAbort = err instanceof Error && err.name === "AbortError";
293
- if (isAbort) {
302
+ const isTimeout = err instanceof ApiTimeoutError;
303
+ if (isAbort || isTimeout) {
294
304
  updateTestResults(
295
305
  providerId,
296
306
  createFailedProviderTestResults("Request timed out", "timeout"),
297
307
  );
308
+ } else if (err instanceof Error) {
309
+ updateTestResults(
310
+ providerId,
311
+ createFailedProviderTestResults(err.message, "server_error"),
312
+ );
298
313
  }
299
- // If it's not an abort error, the test results won't be updated
300
- // which means the previous results will persist (or it will show "Not configured" reset state)
301
314
  } finally {
302
315
  // Always clear the countdown and testing state, even if an error occurs
303
316
  clearInterval(intervalId);
@@ -323,18 +336,17 @@ export function ProvidersPanel({
323
336
  });
324
337
  try {
325
338
  const payload = url !== undefined && url.trim() !== "" ? { url } : {};
326
- const res = await fetch(`/api/providers/${providerId}/model-metadata`, {
327
- method: "POST",
328
- headers: { "Content-Type": "application/json" },
329
- body: JSON.stringify(payload),
330
- });
331
- if (!res.ok) {
332
- if (showError) {
333
- setError(await readApiError(res, "Failed to refresh model metadata"));
334
- }
335
- return false;
336
- }
337
- const data = await parseJsonResponse(res, ModelMetadataRefreshResponseSchema);
339
+ const data = await fetchJsonWithTimeout(
340
+ `/api/providers/${providerId}/model-metadata`,
341
+ ModelMetadataRefreshResponseSchema,
342
+ PROVIDER_METADATA_TIMEOUT_MS,
343
+ {
344
+ method: "POST",
345
+ headers: { "Content-Type": "application/json" },
346
+ body: JSON.stringify(payload),
347
+ },
348
+ () => "Failed to refresh model metadata",
349
+ );
338
350
  if (data.imported === 0 && showError) {
339
351
  setError("No matching model metadata found for this provider.");
340
352
  } else {
@@ -343,9 +355,9 @@ export function ProvidersPanel({
343
355
  triggerHighlight(data.provider.id);
344
356
  refreshProviders();
345
357
  return data.imported > 0;
346
- } catch {
358
+ } catch (err) {
347
359
  if (showError) {
348
- setError(NETWORK_ERROR_MESSAGE);
360
+ setError(err instanceof Error ? err.message : NETWORK_ERROR_MESSAGE);
349
361
  }
350
362
  return false;
351
363
  } finally {
@@ -362,16 +374,17 @@ export function ProvidersPanel({
362
374
  function handleAddProvider(data: ProviderFormData): void {
363
375
  void (async () => {
364
376
  try {
365
- const res = await fetch("/api/providers", {
366
- method: "POST",
367
- headers: { "Content-Type": "application/json" },
368
- body: JSON.stringify(createProviderPayload(data)),
369
- });
370
- if (!res.ok) {
371
- setError(await readApiError(res, "Failed to add provider"));
372
- return;
373
- }
374
- const newProvider = await parseJsonResponse(res, ProviderConfigSchema);
377
+ const newProvider = await fetchJsonWithTimeout(
378
+ "/api/providers",
379
+ ProviderConfigSchema,
380
+ PROVIDER_WRITE_TIMEOUT_MS,
381
+ {
382
+ method: "POST",
383
+ headers: { "Content-Type": "application/json" },
384
+ body: JSON.stringify(createProviderPayload(data)),
385
+ },
386
+ () => "Failed to add provider",
387
+ );
375
388
  setShowForm(false);
376
389
  triggerHighlight(newProvider.id);
377
390
  if (data.modelMetadataUrl !== undefined && data.modelMetadataUrl.trim() !== "") {
@@ -379,8 +392,8 @@ export function ProvidersPanel({
379
392
  }
380
393
  refreshProviders();
381
394
  await runTest(newProvider.id);
382
- } catch {
383
- setError(NETWORK_ERROR_MESSAGE);
395
+ } catch (err) {
396
+ setError(err instanceof Error ? err.message : NETWORK_ERROR_MESSAGE);
384
397
  }
385
398
  })();
386
399
  }
@@ -389,16 +402,17 @@ export function ProvidersPanel({
389
402
  if (!editingProvider) return;
390
403
  void (async () => {
391
404
  try {
392
- const res = await fetch(`/api/providers/${editingProvider.id}`, {
393
- method: "PUT",
394
- headers: { "Content-Type": "application/json" },
395
- body: JSON.stringify(createProviderPayload(data)),
396
- });
397
- if (!res.ok) {
398
- setError(await readApiError(res, "Failed to update provider"));
399
- return;
400
- }
401
- const updated = await parseJsonResponse(res, ProviderConfigSchema);
405
+ const updated = await fetchJsonWithTimeout(
406
+ `/api/providers/${editingProvider.id}`,
407
+ ProviderConfigSchema,
408
+ PROVIDER_WRITE_TIMEOUT_MS,
409
+ {
410
+ method: "PUT",
411
+ headers: { "Content-Type": "application/json" },
412
+ body: JSON.stringify(createProviderPayload(data)),
413
+ },
414
+ () => "Failed to update provider",
415
+ );
402
416
  setEditingProvider(undefined);
403
417
  triggerHighlight(updated.id);
404
418
  if (data.modelMetadataUrl !== undefined && data.modelMetadataUrl.trim() !== "") {
@@ -415,8 +429,8 @@ export function ProvidersPanel({
415
429
  if (criticalFieldsChanged) {
416
430
  await runTest(updated.id);
417
431
  }
418
- } catch {
419
- setError(NETWORK_ERROR_MESSAGE);
432
+ } catch (err) {
433
+ setError(err instanceof Error ? err.message : NETWORK_ERROR_MESSAGE);
420
434
  }
421
435
  })();
422
436
  }
@@ -432,7 +446,7 @@ export function ProvidersPanel({
432
446
  void (async () => {
433
447
  let res: Response;
434
448
  try {
435
- res = await fetch(`/api/providers/${providerId}`, {
449
+ res = await fetchWithTimeout(`/api/providers/${providerId}`, PROVIDER_DELETE_TIMEOUT_MS, {
436
450
  method: "DELETE",
437
451
  });
438
452
  } catch {
@@ -453,7 +467,7 @@ export function ProvidersPanel({
453
467
  const url = `/api/providers/export${includeKeys ? "?includeKeys=true" : ""}`;
454
468
  void (async () => {
455
469
  try {
456
- const res = await fetch(url);
470
+ const res = await fetchWithTimeout(url, PROVIDER_EXPORT_TIMEOUT_MS);
457
471
  if (!res.ok) {
458
472
  setError("Failed to export providers");
459
473
  return;
@@ -485,7 +499,7 @@ export function ProvidersPanel({
485
499
  void (async () => {
486
500
  try {
487
501
  const text = await file.text();
488
- const res = await fetch("/api/providers/import", {
502
+ const res = await fetchWithTimeout("/api/providers/import", PROVIDER_IMPORT_TIMEOUT_MS, {
489
503
  method: "POST",
490
504
  headers: { "Content-Type": "application/json" },
491
505
  body: JSON.stringify(text),
@@ -9,6 +9,7 @@ import { ProvidersPanel } from "./ProvidersPanel";
9
9
  import { useProviders } from "../../lib/useProviders";
10
10
  import { useStripConfig } from "../../lib/useStripConfig";
11
11
  import { copyTextToClipboard } from "../../lib/clipboard";
12
+ import { fetchJsonWithTimeout } from "../../lib/apiClient";
12
13
  import {
13
14
  MAX_PROVIDER_TEST_TIMEOUT_SECONDS,
14
15
  MAX_SLOW_RESPONSE_THRESHOLD_SECONDS,
@@ -148,6 +149,8 @@ const LogStorageStatsSchema = z.object({
148
149
  sessionArchiveBytes: z.number(),
149
150
  });
150
151
 
152
+ const STORAGE_STATS_FETCH_TIMEOUT_MS = 10_000;
153
+
151
154
  function formatBytes(bytes: number): string {
152
155
  if (bytes < 1024) return `${bytes} B`;
153
156
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
@@ -165,18 +168,14 @@ function StorageSettingsTab(): JSX.Element {
165
168
  setLoading(true);
166
169
  setError(null);
167
170
  try {
168
- const response = await fetch("/api/logs?stats=1");
169
- if (!response.ok) {
170
- setError(`Failed to load storage stats: HTTP ${String(response.status)}`);
171
- return;
172
- }
173
- const data: unknown = await response.json();
174
- const parsed = LogStorageStatsSchema.safeParse(data);
175
- if (!parsed.success) {
176
- setError("Storage stats response was not valid.");
177
- return;
178
- }
179
- setStats(parsed.data);
171
+ const data = await fetchJsonWithTimeout(
172
+ "/api/logs?stats=1",
173
+ LogStorageStatsSchema,
174
+ STORAGE_STATS_FETCH_TIMEOUT_MS,
175
+ undefined,
176
+ (response) => `Failed to load storage stats: HTTP ${String(response.status)}`,
177
+ );
178
+ setStats(data);
180
179
  } catch (err) {
181
180
  setError(err instanceof Error ? err.message : String(err));
182
181
  } finally {
@@ -23,7 +23,7 @@ import { z } from "zod";
23
23
  import type { TimeDisplayFormat } from "../../lib/runtimeConfig";
24
24
  import { formatTimestampRange } from "../../lib/timeDisplay";
25
25
  import { cn, formatTokens } from "../../lib/utils";
26
- import { parseJsonResponse, readApiError } from "../../lib/apiClient";
26
+ import { fetchJsonWithTimeout, fetchWithTimeout } from "../../lib/apiClient";
27
27
  import {
28
28
  KnowledgeCandidateSchema,
29
29
  type KnowledgeCandidate,
@@ -64,6 +64,10 @@ type CandidateLoadState =
64
64
  | { status: "ready"; error: null }
65
65
  | { status: "failed"; error: string };
66
66
 
67
+ const CANDIDATE_GENERATE_TIMEOUT_MS = 30_000;
68
+ const CANDIDATE_PROMOTE_TIMEOUT_MS = 15_000;
69
+ const CANDIDATE_UPDATE_TIMEOUT_MS = 15_000;
70
+
67
71
  type TraceInsightKind = "session" | "tool" | "slow" | "failure" | "candidate";
68
72
 
69
73
  type TraceInsight = {
@@ -655,19 +659,13 @@ export function AgentTraceSummary({
655
659
  setCandidateState({ status: "loading", error: null });
656
660
  void (async () => {
657
661
  try {
658
- const response = await fetch(
662
+ const parsed = await fetchJsonWithTimeout(
659
663
  `/api/knowledge/sessions/${encodeURIComponent(scopeId)}/candidates`,
664
+ CandidateResponseSchema,
665
+ CANDIDATE_GENERATE_TIMEOUT_MS,
660
666
  { method: "POST" },
667
+ (response) => `Candidate generation failed with ${String(response.status)}`,
661
668
  );
662
- if (!response.ok) {
663
- const message = await readApiError(
664
- response,
665
- `Candidate generation failed with ${String(response.status)}`,
666
- );
667
- setCandidateState({ status: "failed", error: message });
668
- return;
669
- }
670
- const parsed = await parseJsonResponse(response, CandidateResponseSchema);
671
669
  setCandidates(parsed.candidates);
672
670
  setCandidatesExpanded(parsed.candidates.length > 0);
673
671
  setCandidateState(
@@ -694,8 +692,9 @@ export function AgentTraceSummary({
694
692
 
695
693
  void (async () => {
696
694
  try {
697
- const response = await fetch(
695
+ const response = await fetchWithTimeout(
698
696
  `/api/knowledge/candidates/${encodeURIComponent(candidateId)}/promote`,
697
+ CANDIDATE_PROMOTE_TIMEOUT_MS,
699
698
  { method: "POST" },
700
699
  );
701
700
  const raw: unknown = await response.json().catch(() => null);
@@ -749,24 +748,17 @@ export function AgentTraceSummary({
749
748
  });
750
749
 
751
750
  try {
752
- const response = await fetch(
751
+ const parsed = await fetchJsonWithTimeout(
753
752
  `/api/knowledge/candidates/${encodeURIComponent(candidateId)}`,
753
+ CandidateUpdateResponseSchema,
754
+ CANDIDATE_UPDATE_TIMEOUT_MS,
754
755
  {
755
756
  method: "PATCH",
756
757
  headers: { "Content-Type": "application/json" },
757
758
  body: JSON.stringify(update),
758
759
  },
760
+ (response) => `Candidate update failed with ${String(response.status)}`,
759
761
  );
760
- if (!response.ok) {
761
- const message = await readApiError(
762
- response,
763
- `Candidate update failed with ${String(response.status)}`,
764
- );
765
- setCandidateState({ status: "failed", error: message });
766
- return false;
767
- }
768
-
769
- const parsed = await parseJsonResponse(response, CandidateUpdateResponseSchema);
770
762
  setCandidates((current) =>
771
763
  current.map((candidate) =>
772
764
  candidate.id === parsed.candidate.id ? parsed.candidate : candidate,