@oh-my-pi/pi-coding-agent 3.5.1337 → 3.8.1337

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.
@@ -6,6 +6,7 @@ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
6
6
  import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
7
7
  import { Type } from "@sinclair/typebox";
8
8
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime";
9
+ import { untilAborted } from "../utils";
9
10
  import { resolveReadPath } from "./path-utils";
10
11
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate";
11
12
 
@@ -69,169 +70,120 @@ Usage:
69
70
  ) => {
70
71
  const absolutePath = resolveReadPath(path, cwd);
71
72
 
72
- return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>(
73
- (resolve, reject) => {
74
- // Check if already aborted
75
- if (signal?.aborted) {
76
- reject(new Error("Operation aborted"));
77
- return;
78
- }
79
-
80
- let aborted = false;
73
+ return untilAborted(signal, async () => {
74
+ // Check if file exists
75
+ await access(absolutePath, constants.R_OK);
76
+
77
+ const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
78
+ const ext = extname(absolutePath).toLowerCase();
79
+
80
+ // Read the file based on type
81
+ let content: (TextContent | ImageContent)[];
82
+ let details: ReadToolDetails | undefined;
83
+
84
+ if (mimeType) {
85
+ // Read as image (binary)
86
+ const buffer = await readFile(absolutePath);
87
+ const base64 = buffer.toString("base64");
88
+
89
+ content = [
90
+ { type: "text", text: `Read image file [${mimeType}]` },
91
+ { type: "image", data: base64, mimeType },
92
+ ];
93
+ } else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
94
+ // Convert document via markitdown
95
+ const result = convertWithMarkitdown(absolutePath);
96
+ if (result.ok) {
97
+ // Apply truncation to converted content
98
+ const truncation = truncateHead(result.content);
99
+ let outputText = truncation.content;
100
+
101
+ if (truncation.truncated) {
102
+ outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
103
+ DEFAULT_MAX_BYTES,
104
+ )]`;
105
+ details = { truncation };
106
+ }
81
107
 
82
- // Set up abort handler
83
- const onAbort = () => {
84
- aborted = true;
85
- reject(new Error("Operation aborted"));
86
- };
108
+ content = [{ type: "text", text: outputText }];
109
+ } else {
110
+ // markitdown not available or failed
111
+ const errorMsg =
112
+ result.error === "markitdown not found"
113
+ ? `markitdown not installed. Install with: pip install markitdown`
114
+ : result.error || "conversion failed";
115
+ content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }];
116
+ }
117
+ } else {
118
+ // Read as text
119
+ const textContent = await readFile(absolutePath, "utf-8");
120
+ const allLines = textContent.split("\n");
121
+ const totalFileLines = allLines.length;
122
+
123
+ // Apply offset if specified (1-indexed to 0-indexed)
124
+ const startLine = offset ? Math.max(0, offset - 1) : 0;
125
+ const startLineDisplay = startLine + 1; // For display (1-indexed)
126
+
127
+ // Check if offset is out of bounds
128
+ if (startLine >= allLines.length) {
129
+ throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
130
+ }
87
131
 
88
- if (signal) {
89
- signal.addEventListener("abort", onAbort, { once: true });
132
+ // If limit is specified by user, use it; otherwise we'll let truncateHead decide
133
+ let selectedContent: string;
134
+ let userLimitedLines: number | undefined;
135
+ if (limit !== undefined) {
136
+ const endLine = Math.min(startLine + limit, allLines.length);
137
+ selectedContent = allLines.slice(startLine, endLine).join("\n");
138
+ userLimitedLines = endLine - startLine;
139
+ } else {
140
+ selectedContent = allLines.slice(startLine).join("\n");
90
141
  }
91
142
 
92
- // Perform the read operation
93
- (async () => {
94
- try {
95
- // Check if file exists
96
- await access(absolutePath, constants.R_OK);
97
-
98
- // Check if aborted before reading
99
- if (aborted) {
100
- return;
101
- }
102
-
103
- const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath);
104
- const ext = extname(absolutePath).toLowerCase();
105
-
106
- // Read the file based on type
107
- let content: (TextContent | ImageContent)[];
108
- let details: ReadToolDetails | undefined;
109
-
110
- if (mimeType) {
111
- // Read as image (binary)
112
- const buffer = await readFile(absolutePath);
113
- const base64 = buffer.toString("base64");
114
-
115
- content = [
116
- { type: "text", text: `Read image file [${mimeType}]` },
117
- { type: "image", data: base64, mimeType },
118
- ];
119
- } else if (CONVERTIBLE_EXTENSIONS.has(ext)) {
120
- // Convert document via markitdown
121
- const result = convertWithMarkitdown(absolutePath);
122
- if (result.ok) {
123
- // Apply truncation to converted content
124
- const truncation = truncateHead(result.content);
125
- let outputText = truncation.content;
126
-
127
- if (truncation.truncated) {
128
- outputText += `\n\n[Document converted via markitdown. Output truncated to $formatSize(
129
- DEFAULT_MAX_BYTES,
130
- )]`;
131
- details = { truncation };
132
- }
133
-
134
- content = [{ type: "text", text: outputText }];
135
- } else {
136
- // markitdown not available or failed
137
- const errorMsg =
138
- result.error === "markitdown not found"
139
- ? `markitdown not installed. Install with: pip install markitdown`
140
- : result.error || "conversion failed";
141
- content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }];
142
- }
143
- } else {
144
- // Read as text
145
- const textContent = await readFile(absolutePath, "utf-8");
146
- const allLines = textContent.split("\n");
147
- const totalFileLines = allLines.length;
148
-
149
- // Apply offset if specified (1-indexed to 0-indexed)
150
- const startLine = offset ? Math.max(0, offset - 1) : 0;
151
- const startLineDisplay = startLine + 1; // For display (1-indexed)
152
-
153
- // Check if offset is out of bounds
154
- if (startLine >= allLines.length) {
155
- throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
156
- }
157
-
158
- // If limit is specified by user, use it; otherwise we'll let truncateHead decide
159
- let selectedContent: string;
160
- let userLimitedLines: number | undefined;
161
- if (limit !== undefined) {
162
- const endLine = Math.min(startLine + limit, allLines.length);
163
- selectedContent = allLines.slice(startLine, endLine).join("\n");
164
- userLimitedLines = endLine - startLine;
165
- } else {
166
- selectedContent = allLines.slice(startLine).join("\n");
167
- }
168
-
169
- // Apply truncation (respects both line and byte limits)
170
- const truncation = truncateHead(selectedContent);
171
-
172
- let outputText: string;
173
-
174
- if (truncation.firstLineExceedsLimit) {
175
- // First line at offset exceeds 30KB - tell model to use bash
176
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
177
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
178
- DEFAULT_MAX_BYTES,
179
- )} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
180
- details = { truncation };
181
- } else if (truncation.truncated) {
182
- // Truncation occurred - build actionable notice
183
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
184
- const nextOffset = endLineDisplay + 1;
185
-
186
- outputText = truncation.content;
187
-
188
- if (truncation.truncatedBy === "lines") {
189
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
190
- } else {
191
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
192
- DEFAULT_MAX_BYTES,
193
- )} limit). Use offset=${nextOffset} to continue]`;
194
- }
195
- details = { truncation };
196
- } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
197
- // User specified limit, there's more content, but no truncation
198
- const remaining = allLines.length - (startLine + userLimitedLines);
199
- const nextOffset = startLine + userLimitedLines + 1;
200
-
201
- outputText = truncation.content;
202
- outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
203
- } else {
204
- // No truncation, no user limit exceeded
205
- outputText = truncation.content;
206
- }
207
-
208
- content = [{ type: "text", text: outputText }];
209
- }
210
-
211
- // Check if aborted after reading
212
- if (aborted) {
213
- return;
214
- }
215
-
216
- // Clean up abort handler
217
- if (signal) {
218
- signal.removeEventListener("abort", onAbort);
219
- }
220
-
221
- resolve({ content, details });
222
- } catch (error: any) {
223
- // Clean up abort handler
224
- if (signal) {
225
- signal.removeEventListener("abort", onAbort);
226
- }
227
-
228
- if (!aborted) {
229
- reject(error);
230
- }
143
+ // Apply truncation (respects both line and byte limits)
144
+ const truncation = truncateHead(selectedContent);
145
+
146
+ let outputText: string;
147
+
148
+ if (truncation.firstLineExceedsLimit) {
149
+ // First line at offset exceeds 30KB - tell model to use bash
150
+ const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
151
+ outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
152
+ DEFAULT_MAX_BYTES,
153
+ )} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
154
+ details = { truncation };
155
+ } else if (truncation.truncated) {
156
+ // Truncation occurred - build actionable notice
157
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
158
+ const nextOffset = endLineDisplay + 1;
159
+
160
+ outputText = truncation.content;
161
+
162
+ if (truncation.truncatedBy === "lines") {
163
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
164
+ } else {
165
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
166
+ DEFAULT_MAX_BYTES,
167
+ )} limit). Use offset=${nextOffset} to continue]`;
231
168
  }
232
- })();
233
- },
234
- );
169
+ details = { truncation };
170
+ } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
171
+ // User specified limit, there's more content, but no truncation
172
+ const remaining = allLines.length - (startLine + userLimitedLines);
173
+ const nextOffset = startLine + userLimitedLines + 1;
174
+
175
+ outputText = truncation.content;
176
+ outputText += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue]`;
177
+ } else {
178
+ // No truncation, no user limit exceeded
179
+ outputText = truncation.content;
180
+ }
181
+
182
+ content = [{ type: "text", text: outputText }];
183
+ }
184
+
185
+ return { content, details };
186
+ });
235
187
  },
236
188
  };
237
189
  }
@@ -1,8 +1,6 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname } from "node:path";
3
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
4
2
  import { Type } from "@sinclair/typebox";
5
- import type { FileDiagnosticsResult, FileFormatResult } from "./lsp/index";
3
+ import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
6
4
  import { resolveToCwd } from "./path-utils";
7
5
 
8
6
  const writeSchema = Type.Object({
@@ -12,21 +10,11 @@ const writeSchema = Type.Object({
12
10
 
13
11
  /** Options for creating the write tool */
14
12
  export interface WriteToolOptions {
15
- /** Callback to format file using LSP after writing */
16
- formatOnWrite?: (absolutePath: string) => Promise<FileFormatResult>;
17
- /** Callback to get LSP diagnostics after writing a file */
18
- getDiagnostics?: (absolutePath: string) => Promise<FileDiagnosticsResult>;
13
+ writethrough?: WritethroughCallback;
19
14
  }
20
15
 
21
16
  /** Details returned by the write tool for TUI rendering */
22
17
  export interface WriteToolDetails {
23
- /** Whether the file was formatted */
24
- wasFormatted: boolean;
25
- /** Format result (if available) */
26
- formatResult?: FileFormatResult;
27
- /** Whether LSP diagnostics were retrieved */
28
- hasDiagnostics: boolean;
29
- /** Diagnostic result (if available) */
30
18
  diagnostics?: FileDiagnosticsResult;
31
19
  }
32
20
 
@@ -34,6 +22,7 @@ export function createWriteTool(
34
22
  cwd: string,
35
23
  options: WriteToolOptions = {},
36
24
  ): AgentTool<typeof writeSchema, WriteToolDetails> {
25
+ const writethrough = options.writethrough ?? writethroughNoop;
37
26
  return {
38
27
  name: "write",
39
28
  label: "Write",
@@ -52,108 +41,26 @@ Usage:
52
41
  signal?: AbortSignal,
53
42
  ) => {
54
43
  const absolutePath = resolveToCwd(path, cwd);
55
- const dir = dirname(absolutePath);
56
44
 
57
- return new Promise<{ content: Array<{ type: "text"; text: string }>; details: WriteToolDetails }>(
58
- (resolve, reject) => {
59
- // Check if already aborted
60
- if (signal?.aborted) {
61
- reject(new Error("Operation aborted"));
62
- return;
63
- }
64
-
65
- let aborted = false;
66
-
67
- // Set up abort handler
68
- const onAbort = () => {
69
- aborted = true;
70
- reject(new Error("Operation aborted"));
71
- };
72
-
73
- if (signal) {
74
- signal.addEventListener("abort", onAbort, { once: true });
75
- }
76
-
77
- // Perform the write operation
78
- (async () => {
79
- try {
80
- // Create parent directories if needed
81
- await mkdir(dir, { recursive: true });
82
-
83
- // Check if aborted before writing
84
- if (aborted) {
85
- return;
86
- }
87
-
88
- // Write the file
89
- await writeFile(absolutePath, content, "utf-8");
90
-
91
- // Check if aborted after writing
92
- if (aborted) {
93
- return;
94
- }
95
-
96
- // Clean up abort handler
97
- if (signal) {
98
- signal.removeEventListener("abort", onAbort);
99
- }
100
-
101
- // Format file if callback provided (before diagnostics)
102
- let formatResult: FileFormatResult | undefined;
103
- if (options.formatOnWrite) {
104
- try {
105
- formatResult = await options.formatOnWrite(absolutePath);
106
- } catch {
107
- // Ignore formatting errors - don't fail the write
108
- }
109
- }
110
-
111
- // Get LSP diagnostics if callback provided (after formatting)
112
- let diagnosticsResult: FileDiagnosticsResult | undefined;
113
- if (options.getDiagnostics) {
114
- try {
115
- diagnosticsResult = await options.getDiagnostics(absolutePath);
116
- } catch {
117
- // Ignore diagnostics errors - don't fail the write
118
- }
119
- }
120
-
121
- // Build result text
122
- let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
123
-
124
- // Note if file was formatted
125
- if (formatResult?.formatted) {
126
- resultText += ` (formatted by ${formatResult.serverName})`;
127
- }
128
-
129
- // Append diagnostics if available and there are issues
130
- if (diagnosticsResult?.available && diagnosticsResult.diagnostics.length > 0) {
131
- resultText += `\n\nLSP Diagnostics (${diagnosticsResult.summary}):\n`;
132
- resultText += diagnosticsResult.diagnostics.map((d) => ` ${d}`).join("\n");
133
- }
134
-
135
- resolve({
136
- content: [{ type: "text", text: resultText }],
137
- details: {
138
- wasFormatted: formatResult?.formatted ?? false,
139
- formatResult,
140
- hasDiagnostics: diagnosticsResult?.available ?? false,
141
- diagnostics: diagnosticsResult,
142
- },
143
- });
144
- } catch (error: any) {
145
- // Clean up abort handler
146
- if (signal) {
147
- signal.removeEventListener("abort", onAbort);
148
- }
149
-
150
- if (!aborted) {
151
- reject(error);
152
- }
153
- }
154
- })();
155
- },
156
- );
45
+ const diagnostics = await writethrough(absolutePath, content, signal);
46
+
47
+ let resultText = `Successfully wrote ${content.length} bytes to ${path}`;
48
+ if (!diagnostics) {
49
+ return {
50
+ content: [{ type: "text", text: resultText }],
51
+ details: {},
52
+ };
53
+ }
54
+
55
+ const messages = diagnostics?.messages;
56
+ if (messages && messages.length > 0) {
57
+ resultText += `\n\nLSP Diagnostics (${diagnostics.summary}):\n`;
58
+ resultText += messages.map((d) => ` ${d}`).join("\n");
59
+ }
60
+ return {
61
+ content: [{ type: "text", text: resultText }],
62
+ details: { diagnostics },
63
+ };
157
64
  },
158
65
  };
159
66
  }
@@ -0,0 +1,187 @@
1
+ // Utility constant for representing aborted operations
2
+ const kAbortError = new Error("Operation aborted");
3
+
4
+ /**
5
+ * Runs a promise-returning function (`pr`). If the given AbortSignal is aborted before or during
6
+ * execution, the promise is rejected with a standard error.
7
+ *
8
+ * @param signal - Optional AbortSignal to cancel the operation
9
+ * @param pr - Function returning a promise to run
10
+ * @returns Promise resolving as `pr` would, or rejecting on abort
11
+ */
12
+ export function untilAborted<T>(signal: AbortSignal | undefined | null, pr: () => Promise<T>): Promise<T> {
13
+ if (!signal) {
14
+ return pr();
15
+ }
16
+
17
+ if (signal.aborted) {
18
+ return Promise.reject(kAbortError);
19
+ }
20
+
21
+ return new Promise((resolve, reject) => {
22
+ const listener = () => reject(kAbortError);
23
+ signal.addEventListener("abort", listener, { once: true });
24
+
25
+ signal.throwIfAborted();
26
+
27
+ pr()
28
+ .then(resolve, reject)
29
+ .finally(() => {
30
+ signal.removeEventListener("abort", listener);
31
+ });
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Memoizes a function with no arguments, calling it once and caching the result.
37
+ *
38
+ * @param fn - Function to be called once
39
+ * @returns A function that returns the cached result of `fn`
40
+ */
41
+ export function once<T>(fn: () => T): () => T {
42
+ let store = undefined as { value: T } | undefined;
43
+ return () => {
44
+ if (store) {
45
+ return store.value;
46
+ }
47
+ const value = fn();
48
+ store = { value };
49
+ return value;
50
+ };
51
+ }
52
+
53
+ // ScopeSignal is a cancellation/helper utility similar to AbortController but
54
+ // allows composition of an existing AbortSignal and/or a timeout. It exposes a
55
+ // simple API for cancellation observation (finally, catch).
56
+ interface ScopeSignalOptions {
57
+ signal?: AbortSignal;
58
+ timeout?: number;
59
+ }
60
+
61
+ const kTimeoutReason = new Error("Timeout");
62
+ const kDisposedReason = new Error("Disposed");
63
+
64
+ /**
65
+ * Type of signal exit (None = disposed, TimedOut = timed out, Aborted = underlying signal aborted)
66
+ */
67
+ enum ExitReason {
68
+ None = 0,
69
+ TimedOut = 1,
70
+ Aborted = 2,
71
+ }
72
+
73
+ /**
74
+ * ScopeSignal: composable cancellation for async work–observes an external AbortSignal and/or a timeout.
75
+ *
76
+ * Use .finally(fn) to register a one-time callback invoked on *any* exit (abort, timeout, or manual dispose).
77
+ * Use .catch(fn) to register a one-time callback invoked only on abort/timeout.
78
+ *
79
+ * Disposing ScopeSignal disables further callbacks.
80
+ */
81
+ export class ScopeSignal implements Disposable {
82
+ #signal: AbortSignal | undefined;
83
+ #timer: NodeJS.Timeout | undefined;
84
+ #exit = undefined as ExitReason | undefined;
85
+ #onAbort: (() => void) | undefined;
86
+ #callbacks?: (() => void)[];
87
+ #reason: unknown | undefined;
88
+
89
+ /**
90
+ * Provides abort/timeout reason (Error or user-defined).
91
+ */
92
+ get reason(): unknown | undefined {
93
+ return this.#reason;
94
+ }
95
+
96
+ /**
97
+ * True if exited due to external AbortSignal or timeout.
98
+ */
99
+ get aborted(): boolean {
100
+ return this.#exit !== undefined && this.#exit > ExitReason.None;
101
+ }
102
+
103
+ /**
104
+ * True if this ScopeSignal timed out (not external abort).
105
+ */
106
+ timedOut(): boolean {
107
+ return this.#exit === ExitReason.TimedOut;
108
+ }
109
+
110
+ /**
111
+ * Create a new ScopeSignal, optionally observing an AbortSignal and/or auto-aborting after a timeout (ms).
112
+ */
113
+ constructor(options?: ScopeSignalOptions) {
114
+ const { signal, timeout } = options ?? {};
115
+
116
+ if (signal?.aborted) {
117
+ this.#abort(ExitReason.Aborted, signal.reason); // Immediately abort if already-aborted
118
+ return;
119
+ }
120
+ if (timeout && timeout <= 0) {
121
+ this.#abort(ExitReason.TimedOut, kTimeoutReason);
122
+ return;
123
+ }
124
+
125
+ // Observe external signal if provided
126
+ if (signal) {
127
+ const onAbort = () => {
128
+ this.#abort(ExitReason.Aborted, signal.reason);
129
+ };
130
+ this.#signal = signal;
131
+ this.#onAbort = onAbort;
132
+ this.#signal.addEventListener("abort", onAbort, { once: true });
133
+ }
134
+
135
+ // Set up timeout if provided
136
+ if (timeout) {
137
+ this.#timer = setTimeout(() => {
138
+ this.#abort(ExitReason.TimedOut, kTimeoutReason);
139
+ }, timeout);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Register a one-time callback invoked on any exit (abort, timeout, or manual dispose).
145
+ * Runs immediately if already exited.
146
+ */
147
+ finally(onfinally: () => void): void {
148
+ if (this.#exit !== undefined) {
149
+ onfinally();
150
+ return;
151
+ }
152
+ this.#callbacks ??= [];
153
+ this.#callbacks.push(onfinally);
154
+ }
155
+
156
+ /**
157
+ * Register a one-time callback invoked only if exited due to abort/timeout (not normal disposal).
158
+ */
159
+ catch(oncatch: (reason: unknown) => void): void {
160
+ this.finally(() => {
161
+ if (this.aborted) {
162
+ oncatch(this.reason);
163
+ }
164
+ });
165
+ }
166
+
167
+ /** Internal: cause exit; only first call takes effect. */
168
+ #abort(exit: ExitReason, reason?: unknown): void {
169
+ if (this.#exit !== undefined) return;
170
+ this.#reason = reason;
171
+ clearTimeout(this.#timer);
172
+ this.#signal?.removeEventListener("abort", this.#onAbort!);
173
+
174
+ this.#exit = exit;
175
+
176
+ const callbacks = this.#callbacks;
177
+ this.#callbacks = undefined;
178
+ callbacks?.forEach((fn) => void fn());
179
+ }
180
+
181
+ /**
182
+ * Dispose: marks as normally exited (not abort/timeout); disables further callback registration.
183
+ */
184
+ [Symbol.dispose](): void {
185
+ this.#abort(ExitReason.None, kDisposedReason);
186
+ }
187
+ }