@tonyclaw/agent-inspector 3.0.6 → 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 (70) hide show
  1. package/.output/cli.js +245 -171
  2. package/.output/nitro.json +1 -1
  3. package/.output/public/assets/{CompareDrawer-DsvO6QyJ.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-m1q-uVTl.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-CWP4pxOU.js → _sessionId-4vk9RMZ5.js} +1 -1
  10. package/.output/public/assets/index-DBEB1rHe.css +1 -0
  11. package/.output/public/assets/{index-zfZUz67K.js → index-DINLvnm9.js} +1 -1
  12. package/.output/public/assets/index-LPIGiUMP.js +1 -0
  13. package/.output/public/assets/{json-viewer-_4r1LBBE.js → json-viewer-CH5dvS2Y.js} +1 -1
  14. package/.output/public/assets/{main-C_nEHNUM.js → main-GWkZn4xy.js} +2 -2
  15. package/.output/server/{_sessionId-CTqCAmUF.mjs → _sessionId-EgsjdkN7.mjs} +3 -3
  16. package/.output/server/_ssr/{CompareDrawer-D6b6TZ-O.mjs → CompareDrawer-CaM8FYuD.mjs} +3 -3
  17. package/.output/server/_ssr/{ProxyViewerContainer-Dp7Va9_T.mjs → ProxyViewerContainer-EJIogcGd.mjs} +580 -344
  18. package/.output/server/_ssr/{ReplayDialog-D_KWQ1GY.mjs → ReplayDialog-BPpJ2-q8.mjs} +58 -23
  19. package/.output/server/_ssr/{RequestAnatomy-CPubXjMX.mjs → RequestAnatomy-DeQAWnZx.mjs} +2 -2
  20. package/.output/server/_ssr/{ResponseView-CsTiNoOE.mjs → ResponseView-Dshm8iQ7.mjs} +2 -2
  21. package/.output/server/_ssr/{StreamingChunkSequence-Bgbzt23U.mjs → StreamingChunkSequence-32yIU2hn.mjs} +30 -20
  22. package/.output/server/_ssr/{index-P1gSpuM5.mjs → index-XRcx9blU.mjs} +2 -2
  23. package/.output/server/_ssr/index.mjs +2 -2
  24. package/.output/server/_ssr/{json-viewer-10ciVGnc.mjs → json-viewer-C0aX5rqC.mjs} +2 -2
  25. package/.output/server/_ssr/{router-BJJ0VhsB.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 +73 -73
  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 +68 -21
  36. package/src/components/ProxyViewerContainer.tsx +30 -19
  37. package/src/components/alerts/AlertsDialog.tsx +3 -3
  38. package/src/components/clients/ClientLogo.tsx +22 -2
  39. package/src/components/ecosystem/AgentLabDialog.tsx +215 -12
  40. package/src/components/groups/GroupsDialog.tsx +31 -43
  41. package/src/components/providers/ImportWizardDialog.tsx +52 -102
  42. package/src/components/providers/ProviderCard.tsx +4 -4
  43. package/src/components/providers/ProvidersPanel.tsx +82 -68
  44. package/src/components/providers/SettingsDialog.tsx +11 -12
  45. package/src/components/proxy-viewer/AgentTraceSummary.tsx +15 -23
  46. package/src/components/proxy-viewer/CompareDrawer.tsx +1 -1
  47. package/src/components/proxy-viewer/ConversationHeader.tsx +2 -2
  48. package/src/components/proxy-viewer/ReplayDialog.tsx +65 -22
  49. package/src/components/proxy-viewer/StreamingChunkSequence.tsx +33 -20
  50. package/src/lib/apiClient.ts +43 -10
  51. package/src/lib/ecosystemContract.ts +32 -0
  52. package/src/lib/export-logs.ts +37 -3
  53. package/src/proxy/ecosystemRegistry.ts +244 -0
  54. package/src/proxy/ecosystemTasks.ts +153 -0
  55. package/src/proxy/platformCommands.ts +212 -0
  56. package/src/proxy/socketTracker.ts +84 -143
  57. package/src/routes/api/ecosystem.packages.$packageId.help.ts +16 -0
  58. package/src/routes/api/ecosystem.packages.$packageId.install.ts +16 -0
  59. package/src/routes/api/ecosystem.packages.$packageId.runner-presets.ts +19 -0
  60. package/src/routes/api/ecosystem.packages.$packageId.upgrade.ts +16 -0
  61. package/src/routes/api/ecosystem.packages.ts +1 -248
  62. package/src/routes/api/ecosystem.tasks.$taskId.ts +16 -0
  63. package/src/routes/api/ecosystem.tasks.ts +12 -0
  64. package/.output/public/assets/ProxyViewerContainer-CfP-komD.js +0 -106
  65. package/.output/public/assets/ReplayDialog-CQlLX1Uo.js +0 -1
  66. package/.output/public/assets/ResponseView-DuLLeYSh.js +0 -3
  67. package/.output/public/assets/StreamingChunkSequence-CN4aRFBx.js +0 -1
  68. package/.output/public/assets/index-0pt1umWP.css +0 -1
  69. package/.output/public/assets/index-DSUEOPbC.js +0 -1
  70. package/.output/server/_tanstack-start-manifest_v-CltlYL3I.mjs +0 -4
@@ -1,7 +1,7 @@
1
1
  import { useState, useEffect, useCallback, useRef, useMemo, useTransition, type JSX } from "react";
2
2
  import { z } from "zod";
3
3
  import { CapturedLogSchema, type CapturedLog } from "../contracts";
4
- import { fetchJson } from "../lib/apiClient";
4
+ import { fetchJson, fetchJsonWithTimeout } from "../lib/apiClient";
5
5
  import { InspectorGroupsListResponseSchema, type InspectorGroup } from "../lib/groupContract";
6
6
  import { ImportLogsResponseSchema, type ImportLogsResponse } from "../lib/logImportContract";
7
7
  import { useStripConfig } from "../lib/useStripConfig";
@@ -118,6 +118,8 @@ const HASH_HIGHLIGHT_MS = 1800;
118
118
  const SESSION_PAGE_LIMIT = 100;
119
119
  const SESSION_RELOAD_PAGE_LIMIT = 500;
120
120
  const MAX_SESSION_RELOAD_PAGES = 200;
121
+ const SESSION_LOGS_FETCH_TIMEOUT_MS = 20_000;
122
+ const SESSION_MEMBERSHIP_FETCH_TIMEOUT_MS = 10_000;
121
123
 
122
124
  function buildLogsStreamUrl(sessionId: string | undefined): string {
123
125
  const params = new URLSearchParams();
@@ -167,6 +169,18 @@ function buildLogIndex(logs: readonly CapturedLog[]): Map<number, number> {
167
169
  return idx;
168
170
  }
169
171
 
172
+ function fetchSessionLogsPage(
173
+ sessionId: string,
174
+ request: SessionPageRequest,
175
+ limit = SESSION_PAGE_LIMIT,
176
+ ): Promise<LogCursorPage> {
177
+ return fetchJsonWithTimeout(
178
+ buildSessionLogsPageUrl(sessionId, request, limit),
179
+ LogCursorPageSchema,
180
+ SESSION_LOGS_FETCH_TIMEOUT_MS,
181
+ );
182
+ }
183
+
170
184
  export function mergeLogsById(
171
185
  previousLogs: readonly CapturedLog[],
172
186
  incomingLogs: readonly CapturedLog[],
@@ -357,7 +371,7 @@ export function ProxyViewerContainer({
357
371
  setSessionPageLoading(true);
358
372
  dismissToast();
359
373
 
360
- void fetchJson(buildSessionLogsPageUrl(initialSessionId, request), LogCursorPageSchema)
374
+ void fetchSessionLogsPage(initialSessionId, request)
361
375
  .then((page) => {
362
376
  if (sessionPageRequestIdRef.current !== requestId) return;
363
377
  const nextLogs = page.logs;
@@ -388,9 +402,10 @@ export function ProxyViewerContainer({
388
402
 
389
403
  void (async () => {
390
404
  const collected: CapturedLog[] = [];
391
- let page = await fetchJson(
392
- buildSessionLogsPageUrl(sessionId, { kind: "oldest" }, SESSION_RELOAD_PAGE_LIMIT),
393
- LogCursorPageSchema,
405
+ let page = await fetchSessionLogsPage(
406
+ sessionId,
407
+ { kind: "oldest" },
408
+ SESSION_RELOAD_PAGE_LIMIT,
394
409
  );
395
410
  collected.push(...page.logs);
396
411
 
@@ -400,13 +415,10 @@ export function ProxyViewerContainer({
400
415
  page.newestLogId !== null &&
401
416
  loadedPages < MAX_SESSION_RELOAD_PAGES
402
417
  ) {
403
- page = await fetchJson(
404
- buildSessionLogsPageUrl(
405
- sessionId,
406
- { kind: "newer", afterLogId: page.newestLogId },
407
- SESSION_RELOAD_PAGE_LIMIT,
408
- ),
409
- LogCursorPageSchema,
418
+ page = await fetchSessionLogsPage(
419
+ sessionId,
420
+ { kind: "newer", afterLogId: page.newestLogId },
421
+ SESSION_RELOAD_PAGE_LIMIT,
410
422
  );
411
423
  collected.push(...page.logs);
412
424
  loadedPages += 1;
@@ -444,13 +456,12 @@ export function ProxyViewerContainer({
444
456
  }
445
457
 
446
458
  let cancelled = false;
447
- void fetch("/api/groups")
448
- .then(async (response) => {
449
- if (!response.ok) return null;
450
- const raw: unknown = await response.json();
451
- const parsed = InspectorGroupsListResponseSchema.safeParse(raw);
452
- return parsed.success ? parsed.data.groups : null;
453
- })
459
+ void fetchJsonWithTimeout(
460
+ "/api/groups",
461
+ InspectorGroupsListResponseSchema,
462
+ SESSION_MEMBERSHIP_FETCH_TIMEOUT_MS,
463
+ )
464
+ .then((response) => response.groups)
454
465
  .then((groups) => {
455
466
  if (cancelled || groups === null) return;
456
467
  setSessionMemberships(buildSessionMembershipEvidence(groups, initialSessionId));
@@ -304,7 +304,7 @@ function AlertList({
304
304
  {categoryLabel(alert.category)}
305
305
  </span>
306
306
  </div>
307
- <div className="mt-1 truncate text-sm font-semibold">{alert.title}</div>
307
+ <div className="mt-1 text-sm font-semibold leading-snug">{alert.title}</div>
308
308
  <div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
309
309
  {alert.message}
310
310
  </div>
@@ -315,8 +315,8 @@ function AlertList({
315
315
  </Badge>
316
316
  ) : null}
317
317
  </div>
318
- <div className="mt-2 flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
319
- <span className="truncate">{providerModel(alert)}</span>
318
+ <div className="mt-2 flex min-w-0 items-start justify-between gap-2 text-[11px] text-muted-foreground">
319
+ <span className="min-w-0 leading-snug">{providerModel(alert)}</span>
320
320
  <span className="shrink-0">{formatTimestamp(alert.lastSeenAt)}</span>
321
321
  </div>
322
322
  </button>
@@ -67,6 +67,20 @@ const CLIENT_LOGOS: Record<Exclude<ClientApp, "unknown">, string> = {
67
67
  "claude-code": ClaudeCodeLogoSvg,
68
68
  };
69
69
 
70
+ const CLIENT_LOGO_FRAMES: Record<Exclude<ClientApp, "unknown">, string> = {
71
+ codex: "border-cyan-200/20 bg-cyan-300/[0.08]",
72
+ opencode: "border-sky-200/25 bg-sky-300/[0.12]",
73
+ "mimo-code": "border-orange-200/25 bg-orange-400/[0.12]",
74
+ "claude-code": "border-amber-100/20 bg-amber-300/[0.09]",
75
+ };
76
+
77
+ const CLIENT_LOGO_IMAGES: Record<Exclude<ClientApp, "unknown">, string> = {
78
+ codex: "p-0.5",
79
+ opencode: "p-1 drop-shadow-[0_0_8px_rgba(125,211,252,0.35)]",
80
+ "mimo-code": "p-0.5 drop-shadow-[0_0_8px_rgba(255,105,0,0.35)]",
81
+ "claude-code": "p-0.5",
82
+ };
83
+
70
84
  export function ClientLogo({
71
85
  client,
72
86
  className,
@@ -82,13 +96,19 @@ export function ClientLogo({
82
96
  return (
83
97
  <span
84
98
  className={cn(
85
- "border border-border bg-muted/40 inline-flex size-6 shrink-0 items-center justify-center rounded-md p-0.5",
99
+ "inline-flex size-6 shrink-0 items-center justify-center rounded-md border p-0 shadow-sm",
100
+ CLIENT_LOGO_FRAMES[client],
86
101
  className,
87
102
  )}
88
103
  aria-label={label}
89
104
  title={label}
90
105
  >
91
- <img src={logoSrc} alt="" className="size-full object-contain" aria-hidden="true" />
106
+ <img
107
+ src={logoSrc}
108
+ alt=""
109
+ className={cn("size-full object-contain", CLIENT_LOGO_IMAGES[client])}
110
+ aria-hidden="true"
111
+ />
92
112
  </span>
93
113
  );
94
114
  }
@@ -1,10 +1,12 @@
1
- import { type JSX, useMemo, useState } from "react";
1
+ import { type JSX, useEffect, useMemo, useState } from "react";
2
2
  import {
3
3
  Activity,
4
4
  Beaker,
5
5
  Check,
6
6
  Copy,
7
+ Download,
7
8
  FlaskConical,
9
+ Loader2,
8
10
  Network,
9
11
  PackageCheck,
10
12
  RefreshCw,
@@ -15,9 +17,12 @@ import {
15
17
  import useSWR from "swr";
16
18
  import {
17
19
  EcosystemPackagesResponseSchema,
20
+ EcosystemTaskResponseSchema,
18
21
  type EcosystemPackage,
19
22
  type EcosystemPackageState,
20
23
  type EcosystemRunnerPreset,
24
+ type EcosystemTask,
25
+ type EcosystemTaskAction,
21
26
  } from "../../lib/ecosystemContract";
22
27
  import { fetchJson } from "../../lib/apiClient";
23
28
  import { copyTextToClipboard } from "../../lib/clipboard";
@@ -87,6 +92,28 @@ async function fetchEcosystemPackages(url: string) {
87
92
  );
88
93
  }
89
94
 
95
+ async function fetchEcosystemTask(url: string) {
96
+ return fetchJson(
97
+ url,
98
+ EcosystemTaskResponseSchema,
99
+ undefined,
100
+ (response) => `Failed to load ecosystem task: ${String(response.status)}`,
101
+ );
102
+ }
103
+
104
+ async function startEcosystemTask(
105
+ packageId: string,
106
+ action: EcosystemTaskAction,
107
+ ): Promise<EcosystemTask> {
108
+ const response = await fetchJson(
109
+ `/api/ecosystem/packages/${encodeURIComponent(packageId)}/${action}`,
110
+ EcosystemTaskResponseSchema,
111
+ { method: "POST" },
112
+ (result) => `Failed to start ecosystem task: ${String(result.status)}`,
113
+ );
114
+ return response.task;
115
+ }
116
+
90
117
  function stateLabel(state: EcosystemPackageState): string {
91
118
  switch (state) {
92
119
  case "installed":
@@ -127,16 +154,68 @@ function versionLabel(pkg: EcosystemPackage): string {
127
154
  return "not published";
128
155
  }
129
156
 
130
- function PackageStatusCard({ pkg }: { pkg: EcosystemPackage }): JSX.Element {
157
+ function taskActionLabel(action: EcosystemTaskAction): string {
158
+ switch (action) {
159
+ case "install":
160
+ return "Install";
161
+ case "upgrade":
162
+ return "Upgrade";
163
+ case "help":
164
+ return "Help";
165
+ case "runner-presets":
166
+ return "Presets";
167
+ }
168
+ }
169
+
170
+ function taskStatusLabel(task: EcosystemTask): string {
171
+ switch (task.status) {
172
+ case "queued":
173
+ return "Queued";
174
+ case "running":
175
+ return `${taskActionLabel(task.action)} running`;
176
+ case "succeeded":
177
+ return `${taskActionLabel(task.action)} complete`;
178
+ case "failed":
179
+ return `${taskActionLabel(task.action)} failed`;
180
+ }
181
+ }
182
+
183
+ function packagePrimaryAction(pkg: EcosystemPackage): EcosystemTaskAction | null {
184
+ switch (pkg.state) {
185
+ case "available":
186
+ return "install";
187
+ case "update-available":
188
+ return "upgrade";
189
+ case "installed":
190
+ case "planned":
191
+ case "unknown":
192
+ return null;
193
+ }
194
+ }
195
+
196
+ function PackageStatusCard({
197
+ pkg,
198
+ activeTask,
199
+ onStartTask,
200
+ }: {
201
+ pkg: EcosystemPackage;
202
+ activeTask: EcosystemTask | null;
203
+ onStartTask: (packageId: string, action: EcosystemTaskAction) => void;
204
+ }): JSX.Element {
205
+ const primaryAction = packagePrimaryAction(pkg);
206
+ const packageTaskRunning =
207
+ activeTask !== null && activeTask.packageId === pkg.id && activeTask.status === "running";
131
208
  return (
132
209
  <div className="rounded-lg bg-white/[0.025] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]">
133
- <div className="flex items-start justify-between gap-3">
210
+ <div className="flex flex-wrap items-start justify-between gap-3">
134
211
  <div className="min-w-0">
135
- <div className="flex min-w-0 items-center gap-2">
212
+ <div className="flex min-w-0 items-start gap-2">
136
213
  <PackageCheck className="size-4 shrink-0 text-white/55" />
137
- <div className="truncate text-sm font-semibold">{pkg.name}</div>
214
+ <div className="text-sm font-semibold leading-snug">{pkg.name}</div>
215
+ </div>
216
+ <div className="mt-1 break-all font-mono text-[11px] leading-snug text-white/35">
217
+ {pkg.npmName}
138
218
  </div>
139
- <div className="mt-1 truncate font-mono text-[11px] text-white/35">{pkg.npmName}</div>
140
219
  </div>
141
220
  <Badge className={cn("shrink-0", stateClassName(pkg.state))} variant="outline">
142
221
  {stateLabel(pkg.state)}
@@ -152,10 +231,87 @@ function PackageStatusCard({ pkg }: { pkg: EcosystemPackage }): JSX.Element {
152
231
  {pkg.installCommand}
153
232
  </span>
154
233
  </div>
234
+ <div className="mt-3 flex flex-wrap items-center gap-2">
235
+ {primaryAction !== null && (
236
+ <Button
237
+ type="button"
238
+ size="sm"
239
+ variant="secondary"
240
+ disabled={packageTaskRunning}
241
+ onClick={() => onStartTask(pkg.id, primaryAction)}
242
+ >
243
+ {packageTaskRunning ? (
244
+ <Loader2 className="size-3.5 animate-spin" />
245
+ ) : (
246
+ <Download className="size-3.5" />
247
+ )}
248
+ {taskActionLabel(primaryAction)}
249
+ </Button>
250
+ )}
251
+ <Button
252
+ type="button"
253
+ size="sm"
254
+ variant="outline"
255
+ disabled={packageTaskRunning || pkg.state === "planned"}
256
+ onClick={() => onStartTask(pkg.id, "help")}
257
+ >
258
+ <Terminal className="size-3.5" />
259
+ Help
260
+ </Button>
261
+ {pkg.id === "eval-harness" && (
262
+ <Button
263
+ type="button"
264
+ size="sm"
265
+ variant="ghost"
266
+ disabled={packageTaskRunning || pkg.state === "planned"}
267
+ onClick={() => onStartTask(pkg.id, "runner-presets")}
268
+ >
269
+ <Beaker className="size-3.5" />
270
+ Presets
271
+ </Button>
272
+ )}
273
+ </div>
155
274
  </div>
156
275
  );
157
276
  }
158
277
 
278
+ function TaskConsole({ task }: { task: EcosystemTask | null }): JSX.Element | null {
279
+ if (task === null) return null;
280
+ const isRunning = task.status === "queued" || task.status === "running";
281
+ return (
282
+ <section className="mt-3 rounded-lg bg-white/[0.025] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]">
283
+ <div className="flex flex-wrap items-center justify-between gap-3">
284
+ <div className="flex min-w-0 items-center gap-2">
285
+ {isRunning ? (
286
+ <Loader2 className="size-4 animate-spin text-cyan-200/75" />
287
+ ) : (
288
+ <Terminal className="size-4 text-white/55" />
289
+ )}
290
+ <div className="min-w-0">
291
+ <div className="text-sm font-semibold">{taskStatusLabel(task)}</div>
292
+ <div className="break-all font-mono text-[11px] text-white/35">{task.command}</div>
293
+ </div>
294
+ </div>
295
+ <Badge
296
+ variant="outline"
297
+ className={cn(
298
+ "shrink-0",
299
+ task.status === "failed"
300
+ ? "border-red-400/20 bg-red-400/8 text-red-100"
301
+ : "border-cyan-300/20 bg-cyan-300/8 text-cyan-100",
302
+ )}
303
+ >
304
+ {task.status}
305
+ </Badge>
306
+ </div>
307
+ <pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-md bg-black/30 p-3 font-mono text-[11px] leading-5 text-white/60 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.045)]">
308
+ {task.output.trim().length > 0 ? task.output : "Waiting for npm output..."}
309
+ </pre>
310
+ {task.error !== null && <div className="mt-2 text-xs text-red-100">{task.error}</div>}
311
+ </section>
312
+ );
313
+ }
314
+
159
315
  function WorkflowTile({ workflow }: { workflow: WorkflowCard }): JSX.Element {
160
316
  const Icon = workflow.icon;
161
317
  return (
@@ -163,7 +319,7 @@ function WorkflowTile({ workflow }: { workflow: WorkflowCard }): JSX.Element {
163
319
  <div className="flex items-start justify-between gap-3">
164
320
  <div className="flex min-w-0 items-center gap-2">
165
321
  <Icon className="size-4 shrink-0 text-cyan-200/75" />
166
- <div className="truncate text-sm font-semibold">{workflow.title}</div>
322
+ <div className="text-sm font-semibold">{workflow.title}</div>
167
323
  </div>
168
324
  <span className="rounded bg-white/[0.04] px-2 py-0.5 font-mono text-[10px] text-white/45">
169
325
  {workflow.label}
@@ -187,13 +343,13 @@ function buildSessionCommand(sessionId: string | null): string {
187
343
  function RunnerPresetCard({ preset }: { preset: EcosystemRunnerPreset }): JSX.Element {
188
344
  return (
189
345
  <div className="rounded-lg bg-white/[0.025] p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.055)]">
190
- <div className="flex items-start justify-between gap-3">
346
+ <div className="flex flex-wrap items-start justify-between gap-3">
191
347
  <div className="min-w-0">
192
- <div className="flex min-w-0 items-center gap-2">
348
+ <div className="flex min-w-0 items-start gap-2">
193
349
  <Rocket className="size-4 shrink-0 text-cyan-200/75" />
194
- <div className="truncate text-sm font-semibold">{preset.name}</div>
350
+ <div className="text-sm font-semibold leading-snug">{preset.name}</div>
195
351
  </div>
196
- <div className="mt-1 font-mono text-[11px] text-white/35">
352
+ <div className="mt-1 break-all font-mono text-[11px] leading-snug text-white/35">
197
353
  {preset.binary} {preset.modelFlag}
198
354
  {preset.variantFlag !== null ? ` ${preset.variantFlag}` : ""}
199
355
  </div>
@@ -220,10 +376,25 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
220
376
  const [open, setOpen] = useState(false);
221
377
  const [copied, setCopied] = useState(false);
222
378
  const [copiedPresets, setCopiedPresets] = useState(false);
379
+ const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
380
+ const [taskError, setTaskError] = useState<string | null>(null);
223
381
  const response = useSWR("/api/ecosystem/packages", fetchEcosystemPackages, {
224
382
  revalidateOnFocus: false,
225
383
  revalidateIfStale: false,
226
384
  });
385
+ const activeTaskResponse = useSWR(
386
+ activeTaskId === null ? null : `/api/ecosystem/tasks/${activeTaskId}`,
387
+ fetchEcosystemTask,
388
+ {
389
+ refreshInterval: (taskResponse) => {
390
+ const task = taskResponse?.task;
391
+ return task !== undefined && (task.status === "queued" || task.status === "running")
392
+ ? 900
393
+ : 0;
394
+ },
395
+ revalidateOnFocus: false,
396
+ },
397
+ );
227
398
  const packages = response.data?.packages ?? [];
228
399
  const runnerPresets = response.data?.runnerPresets ?? [];
229
400
  const installedCount = useMemo(
@@ -248,6 +419,26 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
248
419
  });
249
420
  };
250
421
 
422
+ const activeTask = activeTaskResponse.data?.task ?? null;
423
+
424
+ useEffect(() => {
425
+ if (activeTask === null) return;
426
+ if (activeTask.status !== "succeeded") return;
427
+ void response.mutate();
428
+ }, [activeTask, response]);
429
+
430
+ const handleStartTask = (packageId: string, action: EcosystemTaskAction): void => {
431
+ setTaskError(null);
432
+ void startEcosystemTask(packageId, action)
433
+ .then((task) => {
434
+ setActiveTaskId(task.id);
435
+ void activeTaskResponse.mutate({ task }, { revalidate: true });
436
+ })
437
+ .catch((error: unknown) => {
438
+ setTaskError(error instanceof Error ? error.message : "Failed to start ecosystem task");
439
+ });
440
+ };
441
+
251
442
  return (
252
443
  <Dialog open={open} onOpenChange={setOpen}>
253
444
  <DialogTrigger asChild>
@@ -360,7 +551,12 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
360
551
  )}
361
552
  <div className="grid gap-3 md:grid-cols-3">
362
553
  {packages.map((pkg) => (
363
- <PackageStatusCard key={pkg.id} pkg={pkg} />
554
+ <PackageStatusCard
555
+ key={pkg.id}
556
+ pkg={pkg}
557
+ activeTask={activeTask}
558
+ onStartTask={handleStartTask}
559
+ />
364
560
  ))}
365
561
  {response.isLoading &&
366
562
  packages.length === 0 &&
@@ -372,6 +568,13 @@ export function AgentLabDialog({ currentSessionId, logCount }: AgentLabDialogPro
372
568
  ))}
373
569
  </div>
374
570
  </section>
571
+
572
+ {taskError !== null && (
573
+ <div className="mt-3 rounded-lg bg-red-500/8 p-3 text-xs text-red-100 shadow-[inset_0_0_0_1px_rgba(248,113,113,0.16)]">
574
+ {taskError}
575
+ </div>
576
+ )}
577
+ <TaskConsole task={activeTask} />
375
578
  </div>
376
579
  </DialogContent>
377
580
  </Dialog>
@@ -16,7 +16,7 @@ import { Badge } from "../ui/badge";
16
16
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
17
17
  import { INSPECTOR_ICON_TRIGGER_CLASS } from "../ui/icon-trigger";
18
18
  import { cn } from "../../lib/utils";
19
- import { parseJsonResponse, readApiError } from "../../lib/apiClient";
19
+ import { fetchJsonWithTimeout } from "../../lib/apiClient";
20
20
  import {
21
21
  DeleteInspectorGroupResponseSchema,
22
22
  DeleteInspectorGroupsResponseSchema,
@@ -53,6 +53,8 @@ type GroupMemberRow = {
53
53
  source: "group" | "export" | "evidence";
54
54
  };
55
55
 
56
+ const GROUP_OPERATION_TIMEOUT_MS = 20_000;
57
+
56
58
  export function groupDeletePath(groupId: string, deleteEvidence: boolean): string {
57
59
  const path = `/api/groups/${encodeURIComponent(groupId)}`;
58
60
  return deleteEvidence ? `${path}?deleteEvidence=1` : path;
@@ -333,19 +335,13 @@ export function GroupsDialog(): JSX.Element {
333
335
  setExportError(null);
334
336
 
335
337
  try {
336
- const response = await fetch(groupsClearPath(false), { method: "DELETE" });
337
- if (!response.ok) {
338
- showToast({
339
- message: await readApiError(
340
- response,
341
- `Clear failed with status ${String(response.status)}`,
342
- ),
343
- tone: "error",
344
- });
345
- return;
346
- }
347
-
348
- const parsed = await parseJsonResponse(response, DeleteInspectorGroupsResponseSchema);
338
+ const parsed = await fetchJsonWithTimeout(
339
+ groupsClearPath(false),
340
+ DeleteInspectorGroupsResponseSchema,
341
+ GROUP_OPERATION_TIMEOUT_MS,
342
+ { method: "DELETE" },
343
+ (response) => `Clear failed with status ${String(response.status)}`,
344
+ );
349
345
  setExportResult(null);
350
346
  setSelectedGroupId(null);
351
347
  await mutate();
@@ -370,20 +366,17 @@ export function GroupsDialog(): JSX.Element {
370
366
  setExportError(null);
371
367
 
372
368
  try {
373
- const response = await fetch(`/api/groups/${encodeURIComponent(group.id)}/evidence`, {
374
- method: "POST",
375
- headers: { "content-type": "application/json" },
376
- body: JSON.stringify({ latestLogLimit: 10 }),
377
- });
378
-
379
- if (!response.ok) {
380
- setExportError(
381
- await readApiError(response, `Export failed with status ${String(response.status)}`),
382
- );
383
- return;
384
- }
385
-
386
- const parsed = await parseJsonResponse(response, GroupEvidenceExportResultSchema);
369
+ const parsed = await fetchJsonWithTimeout(
370
+ `/api/groups/${encodeURIComponent(group.id)}/evidence`,
371
+ GroupEvidenceExportResultSchema,
372
+ GROUP_OPERATION_TIMEOUT_MS,
373
+ {
374
+ method: "POST",
375
+ headers: { "content-type": "application/json" },
376
+ body: JSON.stringify({ latestLogLimit: 10 }),
377
+ },
378
+ (response) => `Export failed with status ${String(response.status)}`,
379
+ );
387
380
  setExportResult(parsed);
388
381
  await mutate();
389
382
  await mutateEvidence();
@@ -424,18 +417,13 @@ export function GroupsDialog(): JSX.Element {
424
417
  setDeleteError(null);
425
418
 
426
419
  try {
427
- const response = await fetch(groupDeletePath(group.id, deleteEvidence), {
428
- method: "DELETE",
429
- });
430
-
431
- if (!response.ok) {
432
- setDeleteError(
433
- await readApiError(response, `Delete failed with status ${String(response.status)}`),
434
- );
435
- return;
436
- }
437
-
438
- await parseJsonResponse(response, DeleteInspectorGroupResponseSchema);
420
+ await fetchJsonWithTimeout(
421
+ groupDeletePath(group.id, deleteEvidence),
422
+ DeleteInspectorGroupResponseSchema,
423
+ GROUP_OPERATION_TIMEOUT_MS,
424
+ { method: "DELETE" },
425
+ (response) => `Delete failed with status ${String(response.status)}`,
426
+ );
439
427
  setExportResult((current) =>
440
428
  current !== null && current.group.id === group.id ? null : current,
441
429
  );
@@ -598,7 +586,7 @@ function GroupList({
598
586
  )}
599
587
  >
600
588
  <div className="flex min-w-0 items-center justify-between gap-2">
601
- <span className="min-w-0 truncate text-sm font-medium">{group.title}</span>
589
+ <span className="min-w-0 text-sm font-medium leading-snug">{group.title}</span>
602
590
  <Badge variant="outline" className={statusClassName(group.status)}>
603
591
  {statusLabel(group.status)}
604
592
  </Badge>
@@ -673,7 +661,7 @@ function GroupDetails({
673
661
  <div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
674
662
  <div className="min-w-0 space-y-1">
675
663
  <div className="flex min-w-0 flex-wrap items-center gap-2">
676
- <h3 className="min-w-0 truncate text-base font-semibold">{group.title}</h3>
664
+ <h3 className="min-w-0 text-base font-semibold leading-snug">{group.title}</h3>
677
665
  <Badge variant="outline" className={statusClassName(group.status)}>
678
666
  {statusLabel(group.status)}
679
667
  </Badge>
@@ -1119,7 +1107,7 @@ function MemberRow({
1119
1107
  </div>
1120
1108
  </td>
1121
1109
  <td className="max-w-52 px-3 py-2">
1122
- <span className="block truncate">{providerModel(member)}</span>
1110
+ <span className="block whitespace-normal leading-snug">{providerModel(member)}</span>
1123
1111
  </td>
1124
1112
  <td className="px-3 py-2">
1125
1113
  <Badge variant="outline" className={statusClassName(member.status)}>