@tonyclaw/agent-inspector 2.0.31 → 2.0.32

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 (33) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/{CompareDrawer-tIUf2EJm.js → CompareDrawer-Dgi0hhg5.js} +1 -1
  3. package/.output/public/assets/{ProxyViewerContainer-BxRaXhKR.js → ProxyViewerContainer-4dG0nLRp.js} +28 -28
  4. package/.output/public/assets/{ReplayDialog-D6v1xcuC.js → ReplayDialog-BL6BwDuI.js} +1 -1
  5. package/.output/public/assets/{RequestAnatomy-D6PniTlx.js → RequestAnatomy-DZMmiIiD.js} +1 -1
  6. package/.output/public/assets/{ResponseView-Sx8PjuvU.js → ResponseView-DJLOiTYq.js} +1 -1
  7. package/.output/public/assets/{StreamingChunkSequence-D-Rbfnxh.js → StreamingChunkSequence--SxZ5cxc.js} +1 -1
  8. package/.output/public/assets/_sessionId-C4z6gu9i.js +1 -0
  9. package/.output/public/assets/index-Drpb2qR5.js +1 -0
  10. package/.output/public/assets/{index-Byk60-jA.css → index-fUUi8iQF.css} +1 -1
  11. package/.output/public/assets/{main-B1kdhY98.js → main-CbRIHhJ5.js} +2 -2
  12. package/.output/server/_libs/lucide-react.mjs +9 -9
  13. package/.output/server/{_sessionId-BceHFKf4.mjs → _sessionId-DNfumR8F.mjs} +2 -2
  14. package/.output/server/_ssr/{CompareDrawer-uSxK5Ei5.mjs → CompareDrawer-CzgjhnUa.mjs} +3 -3
  15. package/.output/server/_ssr/{ProxyViewerContainer-_npQ_f2i.mjs → ProxyViewerContainer-BqOcZ7en.mjs} +258 -87
  16. package/.output/server/_ssr/{ReplayDialog-BHAzSLCT.mjs → ReplayDialog-DmEknIL8.mjs} +3 -3
  17. package/.output/server/_ssr/{RequestAnatomy-DjlZLXBd.mjs → RequestAnatomy-DNoiMudY.mjs} +3 -3
  18. package/.output/server/_ssr/{ResponseView-BuDEJ3fl.mjs → ResponseView-C_cRrleT.mjs} +3 -3
  19. package/.output/server/_ssr/{StreamingChunkSequence-DFcu0bD8.mjs → StreamingChunkSequence-DHZ9Q_dL.mjs} +3 -3
  20. package/.output/server/_ssr/{index-Bw6Fw33c.mjs → index-coYS9XyS.mjs} +2 -2
  21. package/.output/server/_ssr/index.mjs +2 -2
  22. package/.output/server/_ssr/{router-D90tMCb3.mjs → router-DUb5lKw1.mjs} +138 -39
  23. package/.output/server/{_tanstack-start-manifest_v-BVM4AUlx.mjs → _tanstack-start-manifest_v-xcG1o8Vt.mjs} +1 -1
  24. package/.output/server/index.mjs +67 -67
  25. package/package.json +1 -1
  26. package/src/components/groups/GroupsDialog.tsx +291 -78
  27. package/src/lib/groupContract.ts +13 -0
  28. package/src/mcp/server.ts +16 -0
  29. package/src/mcp/toolHandlers.ts +22 -0
  30. package/src/proxy/groupStore.ts +52 -2
  31. package/src/routes/api/groups.$groupId.ts +38 -1
  32. package/.output/public/assets/_sessionId-BhNTuZ31.js +0 -1
  33. package/.output/public/assets/index-Kxptlkpg.js +0 -1
@@ -1,11 +1,21 @@
1
1
  import { type JSX, useCallback, useEffect, useMemo, useState } from "react";
2
- import { Check, Copy, Download, ExternalLink, Layers, Loader2, RefreshCw } from "lucide-react";
2
+ import {
3
+ Check,
4
+ Copy,
5
+ Download,
6
+ ExternalLink,
7
+ Layers,
8
+ Loader2,
9
+ RefreshCw,
10
+ Trash2,
11
+ } from "lucide-react";
3
12
  import { Button } from "../ui/button";
4
13
  import { Badge } from "../ui/badge";
5
14
  import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
6
15
  import { cn } from "../../lib/utils";
7
16
  import { parseJsonResponse, readApiError } from "../../lib/apiClient";
8
17
  import {
18
+ DeleteInspectorGroupResponseSchema,
9
19
  GroupEvidenceExportResultSchema,
10
20
  type GroupEvidenceReadResponse,
11
21
  type GroupEvidenceExportResult,
@@ -36,6 +46,11 @@ type GroupMemberRow = {
36
46
  exportMember: InspectorGroupExportMember | null;
37
47
  };
38
48
 
49
+ export function groupDeletePath(groupId: string, deleteEvidence: boolean): string {
50
+ const path = `/api/groups/${encodeURIComponent(groupId)}`;
51
+ return deleteEvidence ? `${path}?deleteEvidence=1` : path;
52
+ }
53
+
39
54
  export function summarizeGroup(group: InspectorGroup): GroupSummary {
40
55
  let completedMembers = 0;
41
56
  let failedMembers = 0;
@@ -218,6 +233,10 @@ export function GroupsDialog(): JSX.Element {
218
233
  const [exportingGroupId, setExportingGroupId] = useState<string | null>(null);
219
234
  const [exportError, setExportError] = useState<string | null>(null);
220
235
  const [exportResult, setExportResult] = useState<GroupEvidenceExportResult | null>(null);
236
+ const [deleteTarget, setDeleteTarget] = useState<InspectorGroup | null>(null);
237
+ const [deleteEvidence, setDeleteEvidence] = useState(false);
238
+ const [deletingGroupId, setDeletingGroupId] = useState<string | null>(null);
239
+ const [deleteError, setDeleteError] = useState<string | null>(null);
221
240
  const { groups, isLoading, error, mutate } = useGroups();
222
241
 
223
242
  const selectedGroup = useMemo(
@@ -280,74 +299,140 @@ export function GroupsDialog(): JSX.Element {
280
299
  [mutate, mutateEvidence],
281
300
  );
282
301
 
283
- return (
284
- <Dialog open={open} onOpenChange={setOpen}>
285
- <DialogTrigger asChild>
286
- <Button
287
- type="button"
288
- variant="ghost"
289
- size="icon"
290
- className="relative size-8"
291
- aria-label="Evaluation groups"
292
- title="Evaluation groups"
293
- >
294
- <Layers className="size-4" />
295
- {isLoading ? (
296
- <Loader2 className="absolute -right-0.5 -top-0.5 size-3 animate-spin text-muted-foreground" />
297
- ) : groups.length > 0 ? (
298
- <span className="absolute -right-1 -top-1 min-w-4 rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
299
- {groups.length}
300
- </span>
301
- ) : null}
302
- <span className="sr-only">Evaluation groups</span>
303
- </Button>
304
- </DialogTrigger>
305
- <DialogContent className="flex max-h-[85vh] max-w-5xl flex-col overflow-hidden">
306
- <DialogHeader>
307
- <DialogTitle className="flex items-center gap-2 pr-8">
308
- <Layers className="size-5 text-muted-foreground" />
309
- <span>Groups</span>
310
- <Badge variant="outline">{formatNumber(groups.length)}</Badge>
311
- </DialogTitle>
312
- </DialogHeader>
302
+ const handleRequestDelete = useCallback((group: InspectorGroup) => {
303
+ setDeleteTarget(group);
304
+ setDeleteEvidence(false);
305
+ setDeleteError(null);
306
+ }, []);
307
+
308
+ const handleDeleteDialogOpenChange = useCallback(
309
+ (nextOpen: boolean) => {
310
+ if (nextOpen || deletingGroupId !== null) return;
311
+ setDeleteTarget(null);
312
+ setDeleteEvidence(false);
313
+ setDeleteError(null);
314
+ },
315
+ [deletingGroupId],
316
+ );
313
317
 
314
- <div className="flex min-h-0 items-center justify-between gap-3 border-b border-border pb-3">
315
- <p className="text-xs text-muted-foreground">
316
- Evaluation batches, model runs, and exported evidence packs.
317
- </p>
318
+ const handleDelete = useCallback(async () => {
319
+ const group = deleteTarget;
320
+ if (group === null) return;
321
+
322
+ setDeletingGroupId(group.id);
323
+ setDeleteError(null);
324
+
325
+ try {
326
+ const response = await fetch(groupDeletePath(group.id, deleteEvidence), {
327
+ method: "DELETE",
328
+ });
329
+
330
+ if (!response.ok) {
331
+ setDeleteError(
332
+ await readApiError(response, `Delete failed with status ${String(response.status)}`),
333
+ );
334
+ return;
335
+ }
336
+
337
+ await parseJsonResponse(response, DeleteInspectorGroupResponseSchema);
338
+ setExportResult((current) =>
339
+ current !== null && current.group.id === group.id ? null : current,
340
+ );
341
+ setSelectedGroupId((current) => (current === group.id ? null : current));
342
+ setDeleteTarget(null);
343
+ setDeleteEvidence(false);
344
+ await mutate();
345
+ await mutateEvidence();
346
+ } catch (err) {
347
+ setDeleteError(err instanceof Error ? err.message : String(err));
348
+ } finally {
349
+ setDeletingGroupId(null);
350
+ }
351
+ }, [deleteEvidence, deleteTarget, mutate, mutateEvidence]);
352
+
353
+ return (
354
+ <>
355
+ <Dialog open={open} onOpenChange={setOpen}>
356
+ <DialogTrigger asChild>
318
357
  <Button
319
358
  type="button"
320
- variant="outline"
321
- size="sm"
322
- className="shrink-0"
323
- onClick={handleRefresh}
359
+ variant="ghost"
360
+ size="icon"
361
+ className="relative size-8"
362
+ aria-label="Evaluation groups"
363
+ title="Evaluation groups"
324
364
  >
325
- <RefreshCw className="size-3.5" />
326
- Refresh
365
+ <Layers className="size-4" />
366
+ {isLoading ? (
367
+ <Loader2 className="absolute -right-0.5 -top-0.5 size-3 animate-spin text-muted-foreground" />
368
+ ) : groups.length > 0 ? (
369
+ <span className="absolute -right-1 -top-1 min-w-4 rounded-full bg-primary px-1 text-[10px] leading-4 text-primary-foreground">
370
+ {groups.length}
371
+ </span>
372
+ ) : null}
373
+ <span className="sr-only">Evaluation groups</span>
327
374
  </Button>
328
- </div>
375
+ </DialogTrigger>
376
+ <DialogContent className="flex max-h-[85vh] max-w-5xl flex-col overflow-hidden">
377
+ <DialogHeader>
378
+ <DialogTitle className="flex items-center gap-2 pr-8">
379
+ <Layers className="size-5 text-muted-foreground" />
380
+ <span>Groups</span>
381
+ <Badge variant="outline">{formatNumber(groups.length)}</Badge>
382
+ </DialogTitle>
383
+ </DialogHeader>
384
+
385
+ <div className="flex min-h-0 items-center justify-between gap-3 border-b border-border pb-3">
386
+ <p className="text-xs text-muted-foreground">
387
+ Evaluation batches, model runs, and exported evidence packs.
388
+ </p>
389
+ <Button
390
+ type="button"
391
+ variant="outline"
392
+ size="sm"
393
+ className="shrink-0"
394
+ onClick={handleRefresh}
395
+ >
396
+ <RefreshCw className="size-3.5" />
397
+ Refresh
398
+ </Button>
399
+ </div>
329
400
 
330
- <div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
331
- <GroupList
332
- groups={groups}
333
- selectedGroupId={selectedGroup?.id ?? null}
334
- isLoading={isLoading}
335
- error={error}
336
- onSelect={setSelectedGroupId}
337
- />
338
- <GroupDetails
339
- group={selectedGroup}
340
- exportResult={selectedExportResult}
341
- evidenceResponse={evidenceResponse}
342
- evidenceLoading={evidenceLoading}
343
- evidenceError={evidenceError}
344
- exportError={exportError}
345
- exportingGroupId={exportingGroupId}
346
- onExport={handleExport}
347
- />
348
- </div>
349
- </DialogContent>
350
- </Dialog>
401
+ <div className="grid min-h-0 flex-1 gap-4 md:grid-cols-[260px_minmax(0,1fr)]">
402
+ <GroupList
403
+ groups={groups}
404
+ selectedGroupId={selectedGroup?.id ?? null}
405
+ isLoading={isLoading}
406
+ error={error}
407
+ onSelect={setSelectedGroupId}
408
+ />
409
+ <GroupDetails
410
+ group={selectedGroup}
411
+ exportResult={selectedExportResult}
412
+ evidenceResponse={evidenceResponse}
413
+ evidenceLoading={evidenceLoading}
414
+ evidenceError={evidenceError}
415
+ exportError={exportError}
416
+ deleteError={deleteError}
417
+ exportingGroupId={exportingGroupId}
418
+ deletingGroupId={deletingGroupId}
419
+ onExport={handleExport}
420
+ onRequestDelete={handleRequestDelete}
421
+ />
422
+ </div>
423
+ </DialogContent>
424
+ </Dialog>
425
+ <DeleteGroupDialog
426
+ group={deleteTarget}
427
+ open={deleteTarget !== null}
428
+ deleteEvidence={deleteEvidence}
429
+ isDeleting={deleteTarget !== null && deletingGroupId === deleteTarget.id}
430
+ error={deleteError}
431
+ onDeleteEvidenceChange={setDeleteEvidence}
432
+ onOpenChange={handleDeleteDialogOpenChange}
433
+ onConfirm={() => void handleDelete()}
434
+ />
435
+ </>
351
436
  );
352
437
  }
353
438
 
@@ -431,8 +516,11 @@ function GroupDetails({
431
516
  evidenceLoading,
432
517
  evidenceError,
433
518
  exportError,
519
+ deleteError,
434
520
  exportingGroupId,
521
+ deletingGroupId,
435
522
  onExport,
523
+ onRequestDelete,
436
524
  }: {
437
525
  group: InspectorGroup | null;
438
526
  exportResult: GroupEvidenceExportResult | null;
@@ -440,8 +528,11 @@ function GroupDetails({
440
528
  evidenceLoading: boolean;
441
529
  evidenceError: Error | undefined;
442
530
  exportError: string | null;
531
+ deleteError: string | null;
443
532
  exportingGroupId: string | null;
533
+ deletingGroupId: string | null;
444
534
  onExport: (group: InspectorGroup) => Promise<void>;
535
+ onRequestDelete: (group: InspectorGroup) => void;
445
536
  }): JSX.Element {
446
537
  if (group === null) {
447
538
  return (
@@ -457,6 +548,7 @@ function GroupDetails({
457
548
  const evidence = evidenceFor(group, exportResult);
458
549
  const rows = buildGroupMemberRows(group, exportResult);
459
550
  const isExporting = exportingGroupId === group.id;
551
+ const isDeleting = deletingGroupId === group.id;
460
552
 
461
553
  return (
462
554
  <div className="min-h-0 overflow-y-auto pr-1">
@@ -475,21 +567,39 @@ function GroupDetails({
475
567
  <span>updated {formatTimestamp(group.updatedAt)}</span>
476
568
  </div>
477
569
  </div>
478
- <Button
479
- type="button"
480
- variant="default"
481
- size="sm"
482
- className="shrink-0"
483
- disabled={isExporting}
484
- onClick={() => void onExport(group)}
485
- >
486
- {isExporting ? (
487
- <Loader2 className="size-3.5 animate-spin" />
488
- ) : (
489
- <Download className="size-3.5" />
490
- )}
491
- Export evidence
492
- </Button>
570
+ <div className="flex shrink-0 items-center gap-2">
571
+ <Button
572
+ type="button"
573
+ variant="outline"
574
+ size="icon"
575
+ className="size-8 text-red-600 hover:text-red-700"
576
+ aria-label="Delete group"
577
+ title="Delete group"
578
+ disabled={isDeleting}
579
+ onClick={() => onRequestDelete(group)}
580
+ >
581
+ {isDeleting ? (
582
+ <Loader2 className="size-3.5 animate-spin" />
583
+ ) : (
584
+ <Trash2 className="size-3.5" />
585
+ )}
586
+ </Button>
587
+ <Button
588
+ type="button"
589
+ variant="default"
590
+ size="sm"
591
+ className="shrink-0"
592
+ disabled={isExporting || isDeleting}
593
+ onClick={() => void onExport(group)}
594
+ >
595
+ {isExporting ? (
596
+ <Loader2 className="size-3.5 animate-spin" />
597
+ ) : (
598
+ <Download className="size-3.5" />
599
+ )}
600
+ Export evidence
601
+ </Button>
602
+ </div>
493
603
  </div>
494
604
 
495
605
  {group.task !== null && group.task.trim().length > 0 && (
@@ -529,12 +639,115 @@ function GroupDetails({
529
639
  </div>
530
640
  )}
531
641
 
642
+ {deleteError !== null && (
643
+ <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
644
+ {deleteError}
645
+ </div>
646
+ )}
647
+
532
648
  <MemberTable rows={rows} />
533
649
  </div>
534
650
  </div>
535
651
  );
536
652
  }
537
653
 
654
+ function DeleteGroupDialog({
655
+ group,
656
+ open,
657
+ deleteEvidence,
658
+ isDeleting,
659
+ error,
660
+ onDeleteEvidenceChange,
661
+ onOpenChange,
662
+ onConfirm,
663
+ }: {
664
+ group: InspectorGroup | null;
665
+ open: boolean;
666
+ deleteEvidence: boolean;
667
+ isDeleting: boolean;
668
+ error: string | null;
669
+ onDeleteEvidenceChange: (deleteEvidence: boolean) => void;
670
+ onOpenChange: (open: boolean) => void;
671
+ onConfirm: () => void;
672
+ }): JSX.Element {
673
+ const title = group?.title ?? "this group";
674
+ const hasEvidence = group?.evidence !== null && group?.evidence !== undefined;
675
+ const evidencePath = group?.evidence?.markdownPath ?? "No evidence pack exported.";
676
+
677
+ return (
678
+ <Dialog open={open} onOpenChange={onOpenChange}>
679
+ <DialogContent className="max-w-md">
680
+ <DialogHeader>
681
+ <DialogTitle className="flex items-center gap-2 pr-8">
682
+ <Trash2 className="size-5 text-red-600" />
683
+ <span>Delete group</span>
684
+ </DialogTitle>
685
+ </DialogHeader>
686
+
687
+ <div className="space-y-3">
688
+ <p className="text-sm text-muted-foreground">
689
+ Delete <span className="font-medium text-foreground">{title}</span>? Session logs and
690
+ run records stay intact.
691
+ </p>
692
+
693
+ <label
694
+ className={cn(
695
+ "flex items-start gap-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-sm",
696
+ hasEvidence ? "cursor-pointer" : "opacity-60",
697
+ )}
698
+ >
699
+ <input
700
+ type="checkbox"
701
+ className="mt-0.5 size-4"
702
+ checked={deleteEvidence}
703
+ disabled={!hasEvidence || isDeleting}
704
+ onChange={(event) => onDeleteEvidenceChange(event.currentTarget.checked)}
705
+ />
706
+ <span className="min-w-0">
707
+ <span className="block font-medium">Also delete exported evidence files</span>
708
+ <code className="mt-1 block truncate font-mono text-[11px] text-muted-foreground">
709
+ {evidencePath}
710
+ </code>
711
+ </span>
712
+ </label>
713
+
714
+ {error !== null && (
715
+ <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
716
+ {error}
717
+ </div>
718
+ )}
719
+ </div>
720
+
721
+ <div className="flex justify-end gap-2 pt-1">
722
+ <Button
723
+ type="button"
724
+ variant="outline"
725
+ size="sm"
726
+ disabled={isDeleting}
727
+ onClick={() => onOpenChange(false)}
728
+ >
729
+ Cancel
730
+ </Button>
731
+ <Button
732
+ type="button"
733
+ variant="destructive"
734
+ size="sm"
735
+ disabled={isDeleting || group === null}
736
+ onClick={onConfirm}
737
+ >
738
+ {isDeleting ? (
739
+ <Loader2 className="size-3.5 animate-spin" />
740
+ ) : (
741
+ <Trash2 className="size-3.5" />
742
+ )}
743
+ Delete
744
+ </Button>
745
+ </div>
746
+ </DialogContent>
747
+ </Dialog>
748
+ );
749
+ }
750
+
538
751
  function SummaryStat({ label, value }: { label: string; value: number }): JSX.Element {
539
752
  return (
540
753
  <div className="rounded-md border border-border bg-muted/20 px-3 py-2">
@@ -92,6 +92,17 @@ export const GroupEvidenceExportOptionsSchema = z
92
92
  })
93
93
  .optional();
94
94
 
95
+ export const DeleteInspectorGroupOptionsSchema = z.object({
96
+ deleteEvidence: z.boolean().optional(),
97
+ });
98
+
99
+ export const DeleteInspectorGroupResponseSchema = z.object({
100
+ groupId: z.string(),
101
+ deleted: z.boolean(),
102
+ evidenceDeleted: z.boolean(),
103
+ evidencePath: z.string().nullable(),
104
+ });
105
+
95
106
  export const InspectorGroupExportMemberSchema = z.object({
96
107
  member: InspectorGroupMemberSchema,
97
108
  run: InspectorRunSchema.nullable(),
@@ -141,6 +152,8 @@ export type CreateInspectorGroupInput = z.infer<typeof CreateInspectorGroupInput
141
152
  export type UpdateInspectorGroupInput = z.infer<typeof UpdateInspectorGroupInputSchema>;
142
153
  export type AddInspectorGroupSessionInput = z.infer<typeof AddInspectorGroupSessionInputSchema>;
143
154
  export type GroupEvidenceExportOptions = z.infer<typeof GroupEvidenceExportOptionsSchema>;
155
+ export type DeleteInspectorGroupOptions = z.infer<typeof DeleteInspectorGroupOptionsSchema>;
156
+ export type DeleteInspectorGroupResponse = z.infer<typeof DeleteInspectorGroupResponseSchema>;
144
157
  export type InspectorGroupExportMember = z.infer<typeof InspectorGroupExportMemberSchema>;
145
158
  export type InspectorGroupEvidenceSummary = z.infer<typeof InspectorGroupEvidenceSummarySchema>;
146
159
  export type GroupEvidenceReadResponse = z.infer<typeof GroupEvidenceReadResponseSchema>;
package/src/mcp/server.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  } from "@modelcontextprotocol/server";
29
29
  import {
30
30
  AddInspectorGroupSessionInputSchema,
31
+ DeleteInspectorGroupOptionsSchema,
31
32
  GroupEvidenceExportOptionsSchema,
32
33
  InspectorGroupSchema,
33
34
  InspectorGroupStatusSchema,
@@ -48,6 +49,7 @@ import {
48
49
  createGroupImpl,
49
50
  createRunImpl,
50
51
  createSessionKnowledgeImpl,
52
+ deleteGroupImpl,
51
53
  exportEvidenceImpl,
52
54
  exportGroupEvidenceImpl,
53
55
  getGroupImpl,
@@ -661,6 +663,19 @@ function registerTools(server: McpServer): void {
661
663
  (args) => safeCall(() => updateGroupImpl(callApi, args)),
662
664
  );
663
665
 
666
+ server.registerTool(
667
+ "inspector_delete_group",
668
+ {
669
+ title: "Delete an Inspector group",
670
+ description:
671
+ "Deletes one declared Inspector group from <dataDir>/groups.json. By default this keeps exported evidence files; set deleteEvidence=true only when the local evidence pack should also be removed.",
672
+ inputSchema: DeleteInspectorGroupOptionsSchema.extend({
673
+ groupId: z.string().min(1).describe("The Inspector group id."),
674
+ }),
675
+ },
676
+ (args) => safeCall(() => deleteGroupImpl(callApi, args)),
677
+ );
678
+
664
679
  server.registerTool(
665
680
  "inspector_add_group_session",
666
681
  {
@@ -1224,6 +1239,7 @@ export const TOOL_NAMES = [
1224
1239
  "inspector_create_group",
1225
1240
  "inspector_get_group",
1226
1241
  "inspector_update_group",
1242
+ "inspector_delete_group",
1227
1243
  "inspector_add_group_session",
1228
1244
  "inspector_export_group_evidence",
1229
1245
  "inspector_list_models",
@@ -18,11 +18,13 @@ import { z } from "zod";
18
18
  import { LoopbackTimeoutError, type CallApiOptions } from "./loopback";
19
19
  import { extractLastUserMessagePreview, extractResponsePreview } from "./previewExtractor";
20
20
  import {
21
+ DeleteInspectorGroupResponseSchema,
21
22
  GroupEvidenceExportResultSchema,
22
23
  InspectorGroupSchema,
23
24
  InspectorGroupsListResponseSchema,
24
25
  type AddInspectorGroupSessionInput,
25
26
  type CreateInspectorGroupInput,
27
+ type DeleteInspectorGroupOptions,
26
28
  type UpdateInspectorGroupInput,
27
29
  } from "../lib/groupContract";
28
30
  import {
@@ -446,6 +448,26 @@ export async function updateGroupImpl(
446
448
  return textJson(parsed.data);
447
449
  }
448
450
 
451
+ export type DeleteGroupArgs = {
452
+ groupId: string;
453
+ } & DeleteInspectorGroupOptions;
454
+
455
+ export async function deleteGroupImpl(
456
+ callApi: CallApiFn,
457
+ args: DeleteGroupArgs,
458
+ ): Promise<ToolResult> {
459
+ const query = args.deleteEvidence === true ? "?deleteEvidence=1" : "";
460
+ const path = `/api/groups/${encodeURIComponent(args.groupId)}${query}`;
461
+ const res = await callApi(path, { method: "DELETE" });
462
+ if (!res.ok) return toolError(`DELETE ${path} returned ${res.status}`);
463
+ const rawBody: unknown = await res.json();
464
+ const parsed = DeleteInspectorGroupResponseSchema.safeParse(rawBody);
465
+ if (!parsed.success) {
466
+ return toolError("DELETE /api/groups/{groupId} returned an unparseable delete result");
467
+ }
468
+ return textJson(parsed.data);
469
+ }
470
+
449
471
  export type AddGroupSessionArgs = {
450
472
  groupId: string;
451
473
  } & AddInspectorGroupSessionInput;
@@ -1,14 +1,17 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { z } from "zod";
5
5
  import {
6
6
  AddInspectorGroupSessionInputSchema,
7
7
  CreateInspectorGroupInputSchema,
8
+ DeleteInspectorGroupOptionsSchema,
8
9
  InspectorGroupSchema,
9
10
  UpdateInspectorGroupInputSchema,
10
11
  type AddInspectorGroupSessionInput,
11
12
  type CreateInspectorGroupInput,
13
+ type DeleteInspectorGroupOptions,
14
+ type DeleteInspectorGroupResponse,
12
15
  type InspectorGroup,
13
16
  type InspectorGroupEvidence,
14
17
  type InspectorGroupMember,
@@ -88,6 +91,33 @@ function sortedGroups(groups: readonly InspectorGroup[]): InspectorGroup[] {
88
91
  return [...groups].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
89
92
  }
90
93
 
94
+ function isPathInside(parentPath: string, childPath: string): boolean {
95
+ const relativePath = relative(parentPath, childPath);
96
+ return (
97
+ relativePath === "" ||
98
+ (relativePath.length > 0 && !relativePath.startsWith("..") && !isAbsolute(relativePath))
99
+ );
100
+ }
101
+
102
+ function evidenceDirectoryFor(group: InspectorGroup): string | null {
103
+ if (group.evidence === null) return null;
104
+
105
+ const root = resolve(getDataDir(), "evidence", "groups");
106
+ const evidenceDir = resolve(dirname(group.evidence.jsonPath));
107
+ return isPathInside(root, evidenceDir) ? evidenceDir : null;
108
+ }
109
+
110
+ function removeEvidenceDirectory(evidencePath: string | null, deleteEvidence: boolean): boolean {
111
+ if (!deleteEvidence || evidencePath === null || !existsSync(evidencePath)) return false;
112
+
113
+ try {
114
+ rmSync(evidencePath, { recursive: true, force: true });
115
+ return !existsSync(evidencePath);
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
91
121
  export function listGroups(): InspectorGroup[] {
92
122
  return sortedGroups(readGroups());
93
123
  }
@@ -153,6 +183,26 @@ export function updateGroup(
153
183
  return updatedGroup;
154
184
  }
155
185
 
186
+ export function deleteGroup(
187
+ groupId: string,
188
+ options: DeleteInspectorGroupOptions = {},
189
+ ): DeleteInspectorGroupResponse | null {
190
+ const parsed = DeleteInspectorGroupOptionsSchema.parse(options);
191
+ const groups = readGroups();
192
+ const group = groups.find((candidate) => candidate.id === groupId);
193
+ if (group === undefined) return null;
194
+
195
+ const evidencePath = evidenceDirectoryFor(group);
196
+ writeGroups(groups.filter((candidate) => candidate.id !== groupId));
197
+
198
+ return {
199
+ groupId: group.id,
200
+ deleted: true,
201
+ evidenceDeleted: removeEvidenceDirectory(evidencePath, parsed.deleteEvidence === true),
202
+ evidencePath,
203
+ };
204
+ }
205
+
156
206
  function matchesExistingMember(
157
207
  member: InspectorGroupMember,
158
208
  memberId: string,