@awak-app/simy-cli 0.1.1 → 0.1.3

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,327 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+
3
+ const MAX_LINE_CHARS = 2_000;
4
+ const STRUCTURED_MARKERS = ["SIMY_RESULT_JSON:", "SIMY_AUDIT_JSON:"];
5
+ const CODEX_MCP_AUTH_WARNING =
6
+ "[Codex] MCP warning: optional connector authorization expired; coding continues.";
7
+
8
+ export function createProviderStreamDecoder({ backend, stream = "stdout", onLine, onUsage }) {
9
+ let pending = "";
10
+ let previous = "";
11
+ let model = "";
12
+
13
+ function consume(value) {
14
+ const text = stripVTControlCharacters(String(value || "")).trim();
15
+ if (!text || STRUCTURED_MARKERS.some((marker) => text.startsWith(marker))) return;
16
+
17
+ let event;
18
+ try {
19
+ event = JSON.parse(text);
20
+ } catch {
21
+ emit(formatPlainLine(backend, stream, text));
22
+ return;
23
+ }
24
+
25
+ model = providerModel(backend, event) || model;
26
+ const usage = extractProviderTokenUsage(backend, event, { model });
27
+ if (usage && onUsage) onUsage(usage);
28
+
29
+ for (const line of formatProviderEvent(backend, event)) emit(line);
30
+ }
31
+
32
+ function emit(line) {
33
+ const text = cleanLine(line);
34
+ if (!text || text === previous) return;
35
+ previous = text;
36
+ onLine(text);
37
+ }
38
+
39
+ return {
40
+ push(chunk) {
41
+ pending += String(chunk || "");
42
+ const lines = pending.split(/\r?\n/);
43
+ pending = lines.pop() || "";
44
+ for (const line of lines) consume(line);
45
+ },
46
+ flush() {
47
+ consume(pending);
48
+ pending = "";
49
+ },
50
+ };
51
+ }
52
+
53
+ export function extractProviderTokenUsage(backend, event, { model = "" } = {}) {
54
+ if (!event || typeof event !== "object") return null;
55
+ if (backend === "claude") {
56
+ if (event.type !== "result") return null;
57
+ return normalizeClaudeUsage(event.usage, model || event.model);
58
+ }
59
+ if (event.type !== "turn.completed") return null;
60
+ return normalizeCodexUsage(event.usage, model || event.model);
61
+ }
62
+
63
+ export function formatProviderEvent(backend, event) {
64
+ if (!event || typeof event !== "object") return [];
65
+ return backend === "claude" ? formatClaudeEvent(event) : formatCodexEvent(event);
66
+ }
67
+
68
+ export function isNonFatalProviderDiagnostic(line) {
69
+ return line === CODEX_MCP_AUTH_WARNING;
70
+ }
71
+
72
+ function formatCodexEvent(event) {
73
+ const prefix = "[Codex]";
74
+ if (event.type === "thread.started") {
75
+ return [`${prefix} session started${shortIdentifier(event.thread_id)}`];
76
+ }
77
+ if (event.type === "turn.started") return [`${prefix} turn started`];
78
+ if (event.type === "turn.completed") {
79
+ return [`${prefix} completed${formatUsage(event.usage)}`];
80
+ }
81
+ if (event.type === "turn.failed" || event.type === "error") {
82
+ return [`${prefix} error: ${errorMessage(event)}`];
83
+ }
84
+ if (event.type !== "item.started" && event.type !== "item.completed") return [];
85
+
86
+ const item = event.item && typeof event.item === "object" ? event.item : {};
87
+ const phase = event.type === "item.started" ? "started" : "completed";
88
+ switch (item.type) {
89
+ case "agent_message":
90
+ return contentLines(prefix, "assistant", item.text || item.message);
91
+ case "reasoning":
92
+ return contentLines(prefix, "reasoning", item.text || item.summary);
93
+ case "command_execution":
94
+ return formatCodexCommand(prefix, item, phase);
95
+ case "file_change":
96
+ return formatFileChanges(prefix, item);
97
+ case "mcp_tool_call":
98
+ return [`${prefix} tool ${phase}: ${toolName(item)}`];
99
+ case "web_search":
100
+ return [`${prefix} web search ${phase}: ${cleanLine(item.query || "search")}`];
101
+ case "todo_list":
102
+ return [`${prefix} plan updated`];
103
+ case "error":
104
+ return [`${prefix} error: ${cleanLine(item.message || "provider item failed")}`];
105
+ default:
106
+ return item.type ? [`${prefix} ${cleanLine(item.type)} ${phase}`] : [];
107
+ }
108
+ }
109
+
110
+ function formatClaudeEvent(event) {
111
+ const prefix = "[Claude Code]";
112
+ if (event.type === "system" && event.subtype === "init") {
113
+ const model = cleanLine(event.model || "");
114
+ return [`${prefix} session started${model ? ` | ${model}` : ""}`];
115
+ }
116
+ if (event.type === "system" && event.subtype === "hook_response") {
117
+ const warning = cleanLine(event.stderr || "");
118
+ return warning ? [`${prefix} hook warning: ${warning}`] : [];
119
+ }
120
+ if (event.type === "assistant") {
121
+ return formatClaudeContent(prefix, event.message?.content);
122
+ }
123
+ if (event.type === "user") {
124
+ return formatClaudeToolResults(prefix, event.message?.content);
125
+ }
126
+ if (event.type === "result") {
127
+ if (event.is_error) return [`${prefix} error: ${cleanLine(event.result || event.subtype)}`];
128
+ const turns = Number.isInteger(event.num_turns) ? ` | ${event.num_turns} turn(s)` : "";
129
+ const duration = Number.isFinite(event.duration_ms)
130
+ ? ` | ${(event.duration_ms / 1_000).toFixed(1)}s`
131
+ : "";
132
+ return [`${prefix} completed${turns}${duration}${formatUsage(event.usage)}`];
133
+ }
134
+ if (event.type === "rate_limit_event") return [`${prefix} rate limit status updated`];
135
+ return [];
136
+ }
137
+
138
+ function formatCodexCommand(prefix, item, phase) {
139
+ const command = cleanLine(item.command || item.cmd || "command");
140
+ if (phase === "started") return [`${prefix} command started: ${command}`];
141
+ const exitCode = Number.isInteger(item.exit_code) ? ` (exit ${item.exit_code})` : "";
142
+ const lines = [`${prefix} command completed${exitCode}: ${command}`];
143
+ const output = item.aggregated_output || item.output || "";
144
+ if (String(output).trim()) lines.push(...contentLines(prefix, "output", output).slice(-6));
145
+ return lines;
146
+ }
147
+
148
+ function formatFileChanges(prefix, item) {
149
+ const changes = Array.isArray(item.changes) ? item.changes : [];
150
+ const paths = changes
151
+ .map((change) => cleanLine(change?.path || change?.file || ""))
152
+ .filter(Boolean);
153
+ if (paths.length > 0) return [`${prefix} files changed: ${paths.join(", ")}`];
154
+ return [`${prefix} file changes completed`];
155
+ }
156
+
157
+ function formatClaudeContent(prefix, content) {
158
+ const blocks = Array.isArray(content) ? content : [];
159
+ const lines = [];
160
+ for (const block of blocks) {
161
+ if (block?.type === "text") lines.push(...contentLines(prefix, "assistant", block.text));
162
+ if (block?.type === "thinking") lines.push(...contentLines(prefix, "reasoning", block.thinking));
163
+ if (block?.type === "tool_use") {
164
+ const detail = toolInputSummary(block.input);
165
+ lines.push(`${prefix} tool started: ${cleanLine(block.name || "tool")}${detail}`);
166
+ }
167
+ }
168
+ return lines;
169
+ }
170
+
171
+ function formatClaudeToolResults(prefix, content) {
172
+ const blocks = Array.isArray(content) ? content : [];
173
+ const lines = [];
174
+ for (const block of blocks) {
175
+ if (block?.type !== "tool_result") continue;
176
+ const status = block.is_error ? "failed" : "completed";
177
+ lines.push(`${prefix} tool ${status}${shortIdentifier(block.tool_use_id)}`);
178
+ const output = toolResultText(block.content);
179
+ if (output) {
180
+ lines.push(
181
+ ...contentLines(prefix, block.is_error ? "error" : "output", output).slice(-6),
182
+ );
183
+ }
184
+ }
185
+ return lines;
186
+ }
187
+
188
+ function contentLines(prefix, label, value) {
189
+ return String(value || "")
190
+ .split(/\r?\n/)
191
+ .map((line) => cleanLine(line))
192
+ .filter(Boolean)
193
+ .map((line) => `${prefix} ${label}: ${line}`);
194
+ }
195
+
196
+ function formatPlainLine(backend, stream, value) {
197
+ const prefix = backend === "claude" ? "[Claude Code]" : "[Codex]";
198
+ if (backend === "codex" && isCodexMcpAuthorizationDiagnostic(value)) {
199
+ return CODEX_MCP_AUTH_WARNING;
200
+ }
201
+ const diagnostic = String(value).match(
202
+ /^\S+\s+(WARN|ERROR|INFO)\s+([\w.-]+(?:::[\w.-]+)*):\s*(.*)$/,
203
+ );
204
+ if (diagnostic) {
205
+ const level = diagnostic[1].toLowerCase();
206
+ return `${prefix} ${level}: ${diagnostic[3] || diagnostic[2]}`;
207
+ }
208
+ return stream === "stderr" ? `${prefix} stderr: ${value}` : `${prefix} ${value}`;
209
+ }
210
+
211
+ function isCodexMcpAuthorizationDiagnostic(value) {
212
+ const text = String(value || "");
213
+ return (
214
+ /(?:rmcp::|codex_mcp::)/.test(text) &&
215
+ /(?:invalid_grant|AuthorizationRequired|OAuth authorization required)/i.test(text)
216
+ );
217
+ }
218
+
219
+ function toolResultText(value) {
220
+ if (typeof value === "string") return value;
221
+ if (!Array.isArray(value)) return "";
222
+ return value
223
+ .map((item) => (typeof item === "string" ? item : item?.text || item?.content || ""))
224
+ .filter(Boolean)
225
+ .join("\n");
226
+ }
227
+
228
+ function formatUsage(usage) {
229
+ if (!usage || typeof usage !== "object") return "";
230
+ const input = integer(usage.input_tokens ?? usage.inputTokens);
231
+ const output = integer(usage.output_tokens ?? usage.outputTokens);
232
+ const cached = integer(usage.cached_input_tokens ?? usage.cache_read_input_tokens);
233
+ const parts = [];
234
+ if (input !== null) parts.push(`${input.toLocaleString("en-US")} in`);
235
+ if (output !== null) parts.push(`${output.toLocaleString("en-US")} out`);
236
+ if (cached !== null) parts.push(`${cached.toLocaleString("en-US")} cached`);
237
+ return parts.length > 0 ? ` | ${parts.join(" / ")}` : "";
238
+ }
239
+
240
+ function normalizeCodexUsage(usage, model) {
241
+ if (!usage || typeof usage !== "object") return null;
242
+ const tokensIn = tokenNumber(usage.input_tokens ?? usage.inputTokens);
243
+ const tokensOut = tokenNumber(usage.output_tokens ?? usage.outputTokens);
244
+ if (tokensIn + tokensOut <= 0) return null;
245
+ const cached = tokenNumber(usage.cached_input_tokens ?? usage.cachedInputTokens);
246
+ return compactUsage({
247
+ tokens_in: tokensIn,
248
+ tokens_out: tokensOut,
249
+ total_tokens: tokensIn + tokensOut,
250
+ cached_input_tokens: cached,
251
+ model: cleanLine(model || usage.model || ""),
252
+ });
253
+ }
254
+
255
+ function normalizeClaudeUsage(usage, model) {
256
+ if (!usage || typeof usage !== "object") return null;
257
+ const uncached = tokenNumber(usage.input_tokens ?? usage.inputTokens);
258
+ const cacheRead = tokenNumber(
259
+ usage.cache_read_input_tokens ?? usage.cacheReadInputTokens,
260
+ );
261
+ const cacheCreation = tokenNumber(
262
+ usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens,
263
+ );
264
+ const tokensOut = tokenNumber(usage.output_tokens ?? usage.outputTokens);
265
+ const tokensIn = uncached + cacheRead + cacheCreation;
266
+ if (tokensIn + tokensOut <= 0) return null;
267
+ return compactUsage({
268
+ tokens_in: tokensIn,
269
+ tokens_out: tokensOut,
270
+ total_tokens: tokensIn + tokensOut,
271
+ uncached_input_tokens: uncached,
272
+ cached_input_tokens: cacheRead,
273
+ cache_creation_input_tokens: cacheCreation,
274
+ model: cleanLine(model || usage.model || ""),
275
+ });
276
+ }
277
+
278
+ function compactUsage(value) {
279
+ return Object.fromEntries(
280
+ Object.entries(value).filter(([, item]) => item !== "" && item !== null && item !== undefined),
281
+ );
282
+ }
283
+
284
+ function providerModel(backend, event) {
285
+ if (backend === "claude" && event.type === "system" && event.subtype === "init") {
286
+ return cleanLine(event.model || "");
287
+ }
288
+ return cleanLine(event.model || "");
289
+ }
290
+
291
+ function tokenNumber(value) {
292
+ return Number.isFinite(value) && value >= 0 ? Math.round(value) : 0;
293
+ }
294
+
295
+ function toolName(item) {
296
+ const server = cleanLine(item.server || item.server_name || "");
297
+ const tool = cleanLine(item.tool || item.tool_name || item.name || "tool");
298
+ return server ? `${server}.${tool}` : tool;
299
+ }
300
+
301
+ function toolInputSummary(input) {
302
+ if (!input || typeof input !== "object") return "";
303
+ const value = input.command || input.file_path || input.path || input.query || input.pattern;
304
+ const summary = cleanLine(value || "");
305
+ return summary ? `: ${summary}` : "";
306
+ }
307
+
308
+ function errorMessage(event) {
309
+ const error = event.error;
310
+ if (typeof error === "string") return cleanLine(error);
311
+ return cleanLine(error?.message || event.message || "provider turn failed");
312
+ }
313
+
314
+ function shortIdentifier(value) {
315
+ const text = cleanLine(value || "");
316
+ return text ? ` | ${text.slice(0, 8)}` : "";
317
+ }
318
+
319
+ function integer(value) {
320
+ return Number.isInteger(value) ? value : null;
321
+ }
322
+
323
+ function cleanLine(value) {
324
+ const text = stripVTControlCharacters(String(value || "")).replace(/\s+/g, " ").trim();
325
+ if (text.length <= MAX_LINE_CHARS) return text;
326
+ return `${text.slice(0, MAX_LINE_CHARS - 3)}...`;
327
+ }
@@ -0,0 +1,216 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdir, readFile, readdir, realpath, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import { normalizeGitHubRemote } from "./workspace-context.js";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ const INVENTORY_VERSION = 1;
11
+ const DEFAULT_MAX_DEPTH = 12;
12
+ const DEFAULT_MAX_REPOSITORIES = 1_000;
13
+ const SKIPPED_DIRECTORIES = new Set([
14
+ ".git",
15
+ ".cache",
16
+ ".npm",
17
+ ".pnpm-store",
18
+ ".Trash",
19
+ ".yarn",
20
+ "Library",
21
+ "node_modules",
22
+ "vendor",
23
+ ]);
24
+
25
+ export function defaultRepositoryScanRoot() {
26
+ return homedir();
27
+ }
28
+
29
+ export function repositoryInventoryPath(inventoryRoot = defaultInventoryRoot()) {
30
+ return join(inventoryRoot, "repository-inventory.json");
31
+ }
32
+
33
+ export async function scanGitRepositories(
34
+ requestedRoot,
35
+ { maxDepth = DEFAULT_MAX_DEPTH, maxRepositories = DEFAULT_MAX_REPOSITORIES } = {},
36
+ ) {
37
+ const root = await realpath(resolve(String(requestedRoot || defaultRepositoryScanRoot())));
38
+ const repositories = [];
39
+ const visited = new Set();
40
+ const pending = [{ directory: root, depth: 0 }];
41
+
42
+ while (pending.length > 0 && repositories.length < maxRepositories) {
43
+ const current = pending.shift();
44
+ if (!current || visited.has(current.directory)) continue;
45
+ visited.add(current.directory);
46
+
47
+ let entries;
48
+ try {
49
+ entries = await readdir(current.directory, { withFileTypes: true });
50
+ } catch (error) {
51
+ if (isSkippableFilesystemError(error)) continue;
52
+ throw error;
53
+ }
54
+
55
+ if (entries.some((entry) => entry.name === ".git")) {
56
+ const repository = await inspectGitRepository(current.directory);
57
+ if (repository) repositories.push(repository);
58
+ continue;
59
+ }
60
+ if (current.depth >= maxDepth) continue;
61
+
62
+ for (const entry of entries) {
63
+ if (!entry.isDirectory() || entry.isSymbolicLink() || shouldSkipDirectory(entry.name)) {
64
+ continue;
65
+ }
66
+ pending.push({
67
+ directory: join(current.directory, entry.name),
68
+ depth: current.depth + 1,
69
+ });
70
+ }
71
+ }
72
+
73
+ return {
74
+ root,
75
+ scannedAt: new Date().toISOString(),
76
+ repositories: mergeRepositoryInventory(repositories),
77
+ truncated: repositories.length >= maxRepositories,
78
+ };
79
+ }
80
+
81
+ export async function readRepositoryInventory(inventoryRoot = defaultInventoryRoot()) {
82
+ try {
83
+ const parsed = JSON.parse(await readFile(repositoryInventoryPath(inventoryRoot), "utf8"));
84
+ if (parsed?.version !== INVENTORY_VERSION) return emptyInventory();
85
+ return {
86
+ version: INVENTORY_VERSION,
87
+ authorizedRoots: uniqueStrings(parsed.authorized_roots),
88
+ repositories: mergeRepositoryInventory(parsed.repositories),
89
+ scannedAt: typeof parsed.scanned_at === "string" ? parsed.scanned_at : null,
90
+ };
91
+ } catch (error) {
92
+ if (error?.code === "ENOENT" || error instanceof SyntaxError) return emptyInventory();
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ export async function writeRepositoryInventory(
98
+ { authorizedRoots = [], repositories = [], scannedAt = new Date().toISOString() },
99
+ inventoryRoot = defaultInventoryRoot(),
100
+ ) {
101
+ const target = repositoryInventoryPath(inventoryRoot);
102
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
103
+ await writeFile(
104
+ target,
105
+ `${JSON.stringify(
106
+ {
107
+ version: INVENTORY_VERSION,
108
+ authorized_roots: uniqueStrings(authorizedRoots),
109
+ scanned_at: scannedAt,
110
+ repositories: mergeRepositoryInventory(repositories).map((item) => ({
111
+ repository: item.repository,
112
+ branch: item.branch,
113
+ default_branch: item.default_branch,
114
+ local_path: item.local_path,
115
+ })),
116
+ },
117
+ null,
118
+ 2,
119
+ )}\n`,
120
+ { encoding: "utf8", mode: 0o600 },
121
+ );
122
+ }
123
+
124
+ export function mergeRepositoryInventory(...inventories) {
125
+ const byPath = new Map();
126
+ for (const item of inventories.flat()) {
127
+ const repository = normalizeGitHubRemote(item?.repository);
128
+ const localPath = String(item?.local_path || item?.localPath || "").trim();
129
+ if (!repository || !localPath) continue;
130
+ byPath.set(resolve(localPath), {
131
+ repository,
132
+ branch: String(item?.branch || "").trim() || "dev",
133
+ default_branch:
134
+ String(item?.default_branch || item?.defaultBranch || "").trim() || "dev",
135
+ local_path: resolve(localPath),
136
+ });
137
+ }
138
+ return [...byPath.values()].sort((left, right) =>
139
+ left.repository.localeCompare(right.repository),
140
+ );
141
+ }
142
+
143
+ export function findRepository(inventory, repository) {
144
+ const expected = normalizeGitHubRemote(repository)?.toLowerCase();
145
+ if (!expected) return null;
146
+ return (
147
+ inventory.find((item) => normalizeGitHubRemote(item?.repository)?.toLowerCase() === expected) ||
148
+ null
149
+ );
150
+ }
151
+
152
+ async function inspectGitRepository(directory) {
153
+ try {
154
+ const [{ stdout: root }, { stdout: remote }, { stdout: branch }] = await Promise.all([
155
+ execFileAsync("git", ["rev-parse", "--show-toplevel"], { cwd: directory }),
156
+ execFileAsync("git", ["remote", "get-url", "origin"], { cwd: directory }),
157
+ execFileAsync("git", ["branch", "--show-current"], { cwd: directory }),
158
+ ]);
159
+ const repository = normalizeGitHubRemote(remote);
160
+ if (!repository) return null;
161
+ return {
162
+ repository,
163
+ branch: String(branch || "").trim() || "dev",
164
+ default_branch: await detectDefaultBranch(directory),
165
+ local_path: String(root || "").trim(),
166
+ };
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+
172
+ async function detectDefaultBranch(directory) {
173
+ try {
174
+ const { stdout } = await execFileAsync(
175
+ "git",
176
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
177
+ { cwd: directory },
178
+ );
179
+ const branch = String(stdout || "").trim().replace(/^origin\//, "");
180
+ if (branch) return branch;
181
+ } catch {
182
+ // Fall through to local remote refs for repositories without origin/HEAD.
183
+ }
184
+
185
+ for (const branch of ["dev", "main", "master"]) {
186
+ try {
187
+ await execFileAsync("git", ["show-ref", "--verify", `refs/remotes/origin/${branch}`], {
188
+ cwd: directory,
189
+ });
190
+ return branch;
191
+ } catch {
192
+ // Try the next conventional default branch.
193
+ }
194
+ }
195
+ return "dev";
196
+ }
197
+
198
+ function shouldSkipDirectory(name) {
199
+ return name.startsWith(".") || SKIPPED_DIRECTORIES.has(name);
200
+ }
201
+
202
+ function isSkippableFilesystemError(error) {
203
+ return error?.code === "EACCES" || error?.code === "EPERM" || error?.code === "ENOENT";
204
+ }
205
+
206
+ function uniqueStrings(values) {
207
+ return [...new Set((Array.isArray(values) ? values : []).map(String).filter(Boolean))];
208
+ }
209
+
210
+ function emptyInventory() {
211
+ return { version: INVENTORY_VERSION, authorizedRoots: [], repositories: [], scannedAt: null };
212
+ }
213
+
214
+ function defaultInventoryRoot() {
215
+ return process.env.SIMY_HOME?.trim() || join(homedir(), ".simy");
216
+ }
@@ -0,0 +1,44 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ export class LocalRunRegistry extends EventEmitter {
4
+ #runs = new Map();
5
+ #listeners = new Map();
6
+
7
+ create(run) {
8
+ if (this.#runs.has(run.id)) throw new Error(`Run ${run.id} already exists.`);
9
+ this.#runs.set(run.id, run);
10
+ const listener = (event) => {
11
+ this.emit("event", { run, event });
12
+ this.emit("change", this.list());
13
+ };
14
+ this.#listeners.set(run.id, listener);
15
+ run.emitter.on("event", listener);
16
+ this.emit("event", { run, event: { type: "created", run_id: run.id } });
17
+ this.emit("change", this.list());
18
+ return run;
19
+ }
20
+
21
+ get(runId) {
22
+ return this.#runs.get(runId) ?? null;
23
+ }
24
+
25
+ has(runId) {
26
+ return this.#runs.has(runId);
27
+ }
28
+
29
+ list() {
30
+ return [...this.#runs.values()].sort((left, right) => {
31
+ const leftTime = Date.parse(left.snapshot?.updated_at || left.startedAt || 0) || 0;
32
+ const rightTime = Date.parse(right.snapshot?.updated_at || right.startedAt || 0) || 0;
33
+ return rightTime - leftTime;
34
+ });
35
+ }
36
+
37
+ close() {
38
+ for (const [runId, listener] of this.#listeners) {
39
+ this.#runs.get(runId)?.emitter.off("event", listener);
40
+ }
41
+ this.#listeners.clear();
42
+ this.removeAllListeners();
43
+ }
44
+ }