@bacnh85/pi-subagent 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Structured result extraction (parent-side, no child XML contract).
3
+ *
4
+ * Best-effort parsing of a child's final assistant message into sections.
5
+ * Detects markdown headers (## Findings, ## Files, etc.) when present, but
6
+ * degrades gracefully to plain-text summary when they're absent.
7
+ *
8
+ * Deliberately rejects pi-task's <task_result> XML envelope injection
9
+ * (brittle prompt contract). We structure the output ourselves.
10
+ */
11
+
12
+ export interface StructuredResult {
13
+ /** One-line summary (first non-empty sentence/line, capped). */
14
+ summary: string;
15
+ /** Full raw output, unmodified. */
16
+ fullOutput: string;
17
+ /** Detected "## Findings" / "## Evidence" section, if present. */
18
+ findings?: string;
19
+ /** Detected "## Files" / "## Changed files" section, if present. */
20
+ files?: string;
21
+ /** Detected "## Caveats" / "## Risks" section, if present. */
22
+ caveats?: string;
23
+ /** Detected "## Next steps" / "## Recommendations" section, if present. */
24
+ nextSteps?: string;
25
+ }
26
+
27
+ const SUMMARY_MAX_CHARS = 200;
28
+
29
+ // Header aliases — case-insensitive, match the heading text after "## ".
30
+ const FINDINGS_HEADERS = ["findings", "evidence", "results", "analysis", "details"];
31
+ const FILES_HEADERS = ["files", "changed files", "modified files", "changes"];
32
+ const CAVEATS_HEADERS = ["caveats", "risks", "limitations", "warnings"];
33
+ const NEXT_STEPS_HEADERS = ["next steps", "recommendations", "follow-up", "follow up", "action items"];
34
+
35
+ /**
36
+ * Parse a raw output string into a StructuredResult.
37
+ * If no markdown headers are found, returns summary + fullOutput only.
38
+ */
39
+ export function parseStructuredResult(rawOutput: string): StructuredResult {
40
+ const fullOutput = rawOutput.trim();
41
+ const summary = extractSummary(fullOutput);
42
+
43
+ // Split into sections by "## Header" lines.
44
+ const sections = splitByMarkdownHeaders(fullOutput);
45
+ if (Object.keys(sections).length === 0) {
46
+ return { summary, fullOutput };
47
+ }
48
+
49
+ const result: StructuredResult = { summary, fullOutput };
50
+ const find = (aliases: string[]): string | undefined => {
51
+ for (const [header, body] of Object.entries(sections)) {
52
+ if (aliases.some((a) => header === a || header.includes(a))) {
53
+ return body.trim() || undefined;
54
+ }
55
+ }
56
+ return undefined;
57
+ };
58
+ const findings = find(FINDINGS_HEADERS);
59
+ const files = find(FILES_HEADERS);
60
+ const caveats = find(CAVEATS_HEADERS);
61
+ const nextSteps = find(NEXT_STEPS_HEADERS);
62
+ if (findings) result.findings = findings;
63
+ if (files) result.files = files;
64
+ if (caveats) result.caveats = caveats;
65
+ if (nextSteps) result.nextSteps = nextSteps;
66
+ return result;
67
+ }
68
+
69
+ /**
70
+ * Extract a one-line summary: first non-empty line that isn't a header,
71
+ * capped to SUMMARY_MAX_CHARS.
72
+ */
73
+ export function extractSummary(text: string): string {
74
+ for (const line of text.split("\n")) {
75
+ const trimmed = line.trim();
76
+ if (!trimmed) continue;
77
+ // Skip markdown headers.
78
+ if (/^#{1,6}\s/.test(trimmed)) continue;
79
+ // Take the first sentence if it ends with punctuation, else the whole line.
80
+ const sentenceMatch = trimmed.match(/^.+?[.!?](?:\s|$)/);
81
+ const summary = (sentenceMatch ? sentenceMatch[0] : trimmed).trim();
82
+ return summary.length > SUMMARY_MAX_CHARS ? `${summary.slice(0, SUMMARY_MAX_CHARS - 1)}…` : summary;
83
+ }
84
+ return text.slice(0, SUMMARY_MAX_CHARS);
85
+ }
86
+
87
+ /**
88
+ * Split markdown into { header: body } sections by "## Header" lines.
89
+ * Text before any header is ignored for section extraction.
90
+ * Returns {} if no headers found.
91
+ */
92
+ function splitByMarkdownHeaders(text: string): Record<string, string> {
93
+ const sections: Record<string, string> = {};
94
+ const lines = text.split("\n");
95
+ let currentHeader: string | null = null;
96
+ let currentBody: string[] = [];
97
+ for (const line of lines) {
98
+ const headerMatch = line.match(/^#{1,6}\s+(.+?)\s*$/);
99
+ if (headerMatch) {
100
+ if (currentHeader) sections[currentHeader] = currentBody.join("\n");
101
+ currentHeader = headerMatch[1]!.toLowerCase().replace(/[:*_-]/g, "").trim();
102
+ currentBody = [];
103
+ } else if (currentHeader) {
104
+ currentBody.push(line);
105
+ }
106
+ }
107
+ if (currentHeader) sections[currentHeader] = currentBody.join("\n");
108
+ return sections;
109
+ }
@@ -120,6 +120,8 @@ export interface SubAgentResult {
120
120
  patch?: string;
121
121
  /** Canonical result status (added in 0.6.0). */
122
122
  status?: SubagentStatus;
123
+ /** Wall-clock duration of the run, set by runSubAgent (Date.now() - startedAt). */
124
+ durationMs?: number;
123
125
  }
124
126
 
125
127
  // ---------------------------------------------------------------------------
@@ -308,6 +310,7 @@ export async function runSubAgent(options: {
308
310
  return result;
309
311
  } finally {
310
312
  clearTimers(); cleanupCombined?.();
313
+ result.durationMs = Date.now() - startedAt;
311
314
  }
312
315
  }
313
316
 
@@ -37,7 +37,30 @@ export const ALLOWED_CHILD_TOOLS = BUILTIN_TOOLS;
37
37
  */
38
38
  export const DENIED_CHILD_TOOLS = new Set(["subagent"]);
39
39
 
40
- export const READ_ONLY_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
40
+ // ponytail: keep in sync with pi-plan/extensions/lib/plan-tools.ts READ_ONLY_TOOLS
41
+ // and pi-review/extensions/index.ts SAFE_REVIEW_TOOLS (additions must be mirrored).
42
+ // Used by sandbox: read-only agents to inherit the full read/research toolset
43
+ // (serena/web/munin/fff), not just the 4 built-ins — otherwise read-only research
44
+ // subagents (scout/planner/reviewer) can't use the tools plan mode tells them to prefer.
45
+ export const READ_ONLY_TOOLS: readonly string[] = [
46
+ // Built-in reads
47
+ "read", "grep", "find", "ls",
48
+ // FFF tools
49
+ "ffgrep", "fffind", "resolve_file", "fff_multi_grep", "related_files",
50
+ // Windows tools (read-only: detect, audit, path-convert, classify, doctor)
51
+ "windows_shell_detect", "windows_audit_log",
52
+ "windows_path_to_windows", "windows_path_to_wsl", "windows_path_to_gitbash", "windows_path_quote",
53
+ "windows_safety_classify", "windows_doctor", "windows_tool_discover", "windows_wsl_list_distros",
54
+ // Web tools
55
+ "web_search", "web_extract", "web_map", "web_crawl", "web_screenshot", "web_pdf", "web_status",
56
+ // Serena read-only
57
+ "serena_status", "serena_list_tools", "serena_get_current_config",
58
+ "serena_check_onboarding_performed", "serena_get_symbols_overview", "serena_find_symbol", "serena_find_declaration",
59
+ "serena_find_implementations", "serena_find_referencing_symbols",
60
+ "serena_search_for_pattern", "serena_get_diagnostics_for_file",
61
+ // Munin read-only
62
+ "munin_search", "munin_get", "munin_list", "munin_recent", "munin_capabilities",
63
+ ];
41
64
  export const MUTATION_TOOLS: readonly string[] = ["edit", "write"];
42
65
  export const EXECUTION_TOOLS: readonly string[] = ["bash"];
43
66
 
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Live progress widget for pi-subagent.
3
+ *
4
+ * A persistent above-editor widget that shows what each running subagent is
5
+ * doing right now — spinner, agent, elapsed time, tool-call count, and the
6
+ * latest tool call with done/error/in-progress status. Fed by the live
7
+ * threadStore subscription (per SDK session event), NOT by JSONL polling.
8
+ *
9
+ * Mirrors pi-task's widget UX but cheaper: we have in-process live events.
10
+ */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { truncateToWidth } from "@earendil-works/pi-tui";
14
+ import type { Message } from "@earendil-works/pi-ai";
15
+ import type { SubagentThread } from "./threads.ts";
16
+ import { formatToolCall } from "./render.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Spinner
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
+ const SPINNER_MS = 80;
24
+ const TREE_LAST = "└─"; // pi-task uses └─; keep consistent
25
+ const MAX_WIDTH = 120;
26
+ const MAX_THREADS = 8;
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Theme shim
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export interface WidgetTheme {
33
+ fg(color: string, text: string): string;
34
+ bold?(text: string): string;
35
+ }
36
+
37
+ function color(theme: WidgetTheme | null | undefined, token: string, text: string): string {
38
+ return theme?.fg ? theme.fg(token, text) : text;
39
+ }
40
+
41
+ function bold(theme: WidgetTheme | null | undefined, text: string): string {
42
+ return theme?.bold ? theme.bold(text) : text;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Elapsed formatting
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export function formatMs(ms: number): string {
50
+ if (ms >= 60_000) return `${Math.floor(ms / 60_000)}m ${Math.floor((ms % 60_000) / 1_000)}s`;
51
+ if (ms >= 1_000) return `${(ms / 1_000).toFixed(1)}s`;
52
+ return `${ms}ms`;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Tool-call status derivation from the message stream
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export type ToolStatus = "in_progress" | "done" | "error";
60
+
61
+ export interface RecentToolCall {
62
+ name: string;
63
+ detail: string;
64
+ status: ToolStatus;
65
+ }
66
+
67
+ /**
68
+ * Derive recent tool calls with status by pairing assistant toolCall parts
69
+ * (carrying .id) against later toolResult messages (carrying toolCallId + isError).
70
+ * Returns most-recent-last. Capped at `cap` entries.
71
+ */
72
+ export function deriveRecentToolCalls(messages: Message[], cap = 5): RecentToolCall[] {
73
+ // Map toolCallId -> isError for completed results.
74
+ const resultsById = new Map<string, boolean>();
75
+ for (const msg of messages) {
76
+ if (msg.role === "toolResult") {
77
+ resultsById.set(msg.toolCallId, msg.isError);
78
+ }
79
+ }
80
+ // Walk assistant messages, collect toolCall parts in order.
81
+ const calls: RecentToolCall[] = [];
82
+ for (const msg of messages) {
83
+ if (msg.role !== "assistant") continue;
84
+ for (const part of msg.content) {
85
+ if (part.type !== "toolCall") continue;
86
+ const isError = resultsById.get(part.id);
87
+ const status: ToolStatus = isError === undefined ? "in_progress" : isError ? "error" : "done";
88
+ calls.push({
89
+ name: part.name,
90
+ detail: formatToolCall(part.name, part.arguments ?? {}, (token, text) => text),
91
+ status,
92
+ });
93
+ }
94
+ }
95
+ return calls.slice(-cap);
96
+ }
97
+
98
+ function countToolProgress(messages: Message[]): { toolCount: number; inFlight: number } {
99
+ const resultIds = new Set(messages.filter((m) => m.role === "toolResult").map((m) => m.toolCallId));
100
+ let toolCount = 0;
101
+ let inFlight = 0;
102
+ for (const m of messages) {
103
+ if (m.role !== "assistant") continue;
104
+ for (const p of m.content) {
105
+ if (p.type !== "toolCall") continue;
106
+ if (resultIds.has(p.id)) toolCount++;
107
+ else inFlight++;
108
+ }
109
+ }
110
+ return { toolCount, inFlight };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Rendering
115
+ // ---------------------------------------------------------------------------
116
+
117
+ function statusMark(theme: WidgetTheme | null | undefined, status: ToolStatus, spinner: string): string {
118
+ switch (status) {
119
+ case "done": return color(theme, "success", "✓");
120
+ case "error": return color(theme, "error", "✗");
121
+ case "in_progress":
122
+ default: return color(theme, "accent", spinner);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * One-line live status for a running thread: spinner · Agent · elapsed ·
128
+ * tools · latest tool call. Used by both the widget and the tool-call row.
129
+ */
130
+ export function renderLiveThreadLine(
131
+ thread: SubagentThread,
132
+ theme: WidgetTheme | null | undefined,
133
+ now: number,
134
+ agentColor: string,
135
+ maxCalls = 5,
136
+ ): string {
137
+ const agentName = thread.agentName.charAt(0).toUpperCase() + thread.agentName.slice(1);
138
+ const elapsed = formatMs(now - thread.createdAt);
139
+ const messages = thread.result?.messages ?? [];
140
+ const { toolCount, inFlight } = countToolProgress(messages);
141
+ const spinner = SPINNER_FRAMES[Math.floor(now / SPINNER_MS) % SPINNER_FRAMES.length]!;
142
+
143
+ let line =
144
+ color(theme, "accent", spinner) + " " +
145
+ color(theme, agentColor, bold(theme, agentName)) +
146
+ color(theme, "dim", " · ") +
147
+ color(theme, "warning", elapsed);
148
+ if (toolCount > 0 || inFlight > 0) {
149
+ const parts: string[] = [];
150
+ if (toolCount > 0) parts.push(`${toolCount} tool${toolCount > 1 ? "s" : ""}`);
151
+ if (inFlight > 0) parts.push(`${inFlight} running`);
152
+ line += color(theme, "dim", " · ") + color(theme, "muted", parts.join(", "));
153
+ }
154
+
155
+ // Recent tool calls (up to maxCalls) — list, most-recent-last.
156
+ const recent = deriveRecentToolCalls(messages, maxCalls);
157
+ const hidden = Math.max(0, toolCount + inFlight - recent.length);
158
+ if (hidden > 0 && recent.length >= maxCalls) {
159
+ line += "\n " + color(theme, "dim", `+${hidden} earlier`);
160
+ }
161
+ for (const call of recent) {
162
+ line += "\n " +
163
+ color(theme, "dim", TREE_LAST) + " " +
164
+ statusMark(theme, call.status, spinner) + " " +
165
+ call.detail;
166
+ }
167
+ return line;
168
+ }
169
+
170
+ function renderThread(
171
+ thread: SubagentThread,
172
+ now: number,
173
+ maxWidth: number,
174
+ theme: WidgetTheme | null | undefined,
175
+ ): string[] {
176
+ const lines: string[] = [];
177
+ const agentColor = thread.color ?? "accent";
178
+
179
+ // Header: spinner · Agent · elapsed · tools — then task preview.
180
+ const base = renderLiveThreadLine(thread, theme, now, agentColor).split("\n")[0] ?? "";
181
+ const taskPreview = thread.task.length > 40 ? `${thread.task.slice(0, 37)}...` : thread.task;
182
+ const header = base + (thread.task ? color(theme, "dim", ` — ${taskPreview}`) : "");
183
+ lines.push(truncateToWidth(header, maxWidth));
184
+
185
+ // Latest tool call line (from the shared live-line renderer).
186
+ const full = renderLiveThreadLine(thread, theme, now, agentColor).split("\n");
187
+ if (full.length > 1) {
188
+ lines.push(truncateToWidth(full[1]!, maxWidth));
189
+ }
190
+ return lines;
191
+ }
192
+
193
+ /**
194
+ * Render the full widget for a set of threads.
195
+ * Pure function — takes state, returns lines. No side effects.
196
+ */
197
+ export function renderTaskWidget(params: {
198
+ threads: SubagentThread[];
199
+ width: number;
200
+ theme?: WidgetTheme | null;
201
+ now?: number;
202
+ }): string[] {
203
+ const { threads, width, theme } = params;
204
+ // Only running threads appear in the live widget.
205
+ const running = threads.filter((t) => t.status === "running");
206
+ if (running.length === 0) return [];
207
+
208
+ const now = params.now ?? Date.now();
209
+ const maxWidth = Math.min(width, MAX_WIDTH);
210
+ const spinner = SPINNER_FRAMES[Math.floor(now / SPINNER_MS) % SPINNER_FRAMES.length]!;
211
+
212
+ const lines: string[] = [];
213
+ const shown = running.slice(0, MAX_THREADS);
214
+ for (const thread of shown) {
215
+ lines.push(...renderThread(thread, now, maxWidth, theme));
216
+ lines.push(""); // breathing room between threads
217
+ }
218
+ const hidden = running.length - shown.length;
219
+ if (hidden > 0) {
220
+ lines.push(truncateToWidth(color(theme, "dim", `+ ${hidden} more running`), maxWidth));
221
+ lines.push("");
222
+ }
223
+ return lines;
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Controller — owns the setWidget handle + threadStore subscription
228
+ // ---------------------------------------------------------------------------
229
+
230
+ export interface TaskWidgetController {
231
+ ensureWidget(ctx: ExtensionContext): void;
232
+ requestRender(): void;
233
+ clearWidgetIfIdle(): void;
234
+ dispose(): void;
235
+ }
236
+
237
+ export function createTaskWidgetController(
238
+ getThreads: () => SubagentThread[],
239
+ subscribe?: (listener: () => void) => () => void,
240
+ ): TaskWidgetController {
241
+ let widgetCtx: ExtensionContext | null = null;
242
+ let requestWidgetRender: (() => void) | null = null;
243
+ let widgetTheme: WidgetTheme | null = null;
244
+ let unsubscribe: (() => void) | null = null;
245
+
246
+ // On every threadStore change: re-render if running threads exist,
247
+ // else clear the widget. This is the live-data path — no polling.
248
+ const onStoreChange = (): void => {
249
+ const running = getThreads().some((t) => t.status === "running");
250
+ if (running) {
251
+ if (widgetCtx) requestRender();
252
+ } else {
253
+ clearWidgetIfIdle();
254
+ }
255
+ };
256
+
257
+ function renderWidget(width: number): string[] {
258
+ return renderTaskWidget({ threads: getThreads(), width, theme: widgetTheme });
259
+ }
260
+
261
+ function requestRender(): void {
262
+ requestWidgetRender?.();
263
+ }
264
+
265
+ /**
266
+ * Lazily install the widget + subscription on the first running thread.
267
+ * Idempotent — safe to call on every thread creation.
268
+ */
269
+ function ensureWidget(ctx: ExtensionContext): void {
270
+ if (ctx.mode !== "tui") return;
271
+ // Subscribe once so future threadStore changes drive renders + idle-clear.
272
+ if (!unsubscribe && subscribe) {
273
+ unsubscribe = subscribe(onStoreChange);
274
+ }
275
+ if (widgetCtx) {
276
+ requestRender();
277
+ return;
278
+ }
279
+ widgetCtx = ctx;
280
+ ignoreStaleExtensionCtx(() =>
281
+ ctx.ui.setWidget("pi-subagent", (tui, theme) => {
282
+ widgetTheme = theme ?? null;
283
+ requestWidgetRender = () => tui.requestRender();
284
+ return {
285
+ render: (width: number) => renderWidget(width),
286
+ invalidate: requestWidgetRender,
287
+ dispose: () => {
288
+ widgetTheme = null;
289
+ requestWidgetRender = null;
290
+ },
291
+ };
292
+ }),
293
+ );
294
+ requestRender();
295
+ }
296
+
297
+ /** Clear the widget when no threads are running (called after task completion). */
298
+ function clearWidgetIfIdle(): void {
299
+ const running = getThreads().filter((t) => t.status === "running").length;
300
+ if (running > 0) {
301
+ requestRender();
302
+ return;
303
+ }
304
+ if (widgetCtx) {
305
+ const ctx = widgetCtx;
306
+ ignoreStaleExtensionCtx(() => ctx.ui.setWidget("pi-subagent", undefined));
307
+ widgetCtx = null;
308
+ }
309
+ requestWidgetRender = null;
310
+ }
311
+
312
+ function dispose(): void {
313
+ unsubscribe?.();
314
+ unsubscribe = null;
315
+ if (widgetCtx) {
316
+ const ctx = widgetCtx;
317
+ ignoreStaleExtensionCtx(() => ctx.ui.setWidget("pi-subagent", undefined));
318
+ widgetCtx = null;
319
+ }
320
+ widgetTheme = null;
321
+ requestWidgetRender = null;
322
+ }
323
+
324
+ return { ensureWidget, requestRender, clearWidgetIfIdle, dispose };
325
+ }
326
+
327
+ /**
328
+ * Wrap a ctx operation so a stale (post-replacement) ExtensionContext
329
+ * doesn't crash. Mirrors pi-task's ignoreStaleExtensionCtx.
330
+ * ponytail: minimal try/catch — the only failure mode is a replaced session.
331
+ */
332
+ function ignoreStaleExtensionCtx<T>(fn: () => T): T | undefined {
333
+ try {
334
+ return fn();
335
+ } catch {
336
+ return undefined;
337
+ }
338
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,6 +40,10 @@
40
40
  "extensions/threads.ts",
41
41
  "extensions/thread-viewer.ts",
42
42
  "extensions/security.ts",
43
+ "extensions/background.ts",
44
+ "extensions/history.ts",
45
+ "extensions/result.ts",
46
+ "extensions/widget.ts",
43
47
  "extensions/package.json"
44
48
  ],
45
49
  "pi": {
@@ -56,17 +60,17 @@
56
60
  "check": "npm run typecheck && npm test"
57
61
  },
58
62
  "peerDependencies": {
59
- "@earendil-works/pi-coding-agent": ">=0.80.0 <0.84.0",
60
- "@earendil-works/pi-ai": ">=0.80.0 <0.84.0",
61
- "@earendil-works/pi-agent-core": ">=0.80.0 <0.84.0",
62
- "@earendil-works/pi-tui": ">=0.80.0 <0.84.0",
63
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.85.0",
64
+ "@earendil-works/pi-ai": ">=0.80.0 <0.85.0",
65
+ "@earendil-works/pi-agent-core": ">=0.80.0 <0.85.0",
66
+ "@earendil-works/pi-tui": ">=0.80.0 <0.85.0",
63
67
  "typebox": ">=1.3.0 <2.0.0"
64
68
  },
65
69
  "devDependencies": {
66
- "@earendil-works/pi-agent-core": "^0.83.0",
67
- "@earendil-works/pi-ai": "^0.83.0",
68
- "@earendil-works/pi-coding-agent": "^0.83.0",
69
- "@earendil-works/pi-tui": "^0.83.0",
70
+ "@earendil-works/pi-agent-core": "^0.84.0",
71
+ "@earendil-works/pi-ai": "^0.84.0",
72
+ "@earendil-works/pi-coding-agent": "^0.84.0",
73
+ "@earendil-works/pi-tui": "^0.84.0",
70
74
  "@types/mocha": "^10.0.10",
71
75
  "@types/node": "^20.19.43",
72
76
  "mocha": "^10.8.2",