@abelxiaoxing/cadence 1.0.0 → 1.0.1

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.
package/README.md CHANGED
@@ -1,41 +1,69 @@
1
1
  # @abelxiaoxing/cadence
2
2
 
3
- Prompts, skills, and a private orchestration extension exposing the Abel four-stage workflow:
3
+ [![npm](https://img.shields.io/npm/v/@abelxiaoxing/cadence)](https://www.npmjs.com/package/@abelxiaoxing/cadence)
4
+ [![GitHub](https://img.shields.io/github/license/abelxiaoxing/cadence)](https://github.com/abelxiaoxing/cadence)
4
5
 
5
- - `/abel-init [project-path]` — initialize OpenSpec and repair AGENTS indexes.
6
- - `/abel-design <requirement> | --change <change_name>` — parallelize read-only exploration and design a dependency/conflict-aware task DAG through Gate A and Gate B.
7
- - `/abel-implement <change_name>` — orchestrate task-local professional Agents over the approved DAG with delegated Red-Green-Refactor and parent-owned verification.
8
- - `/abel-diagnose <problem-description>` — verify a root cause before a minimal repair.
6
+ **Cadence** 是一个面向 pi 的规范驱动四阶段工作流扩展包,内置私有代理编排:
9
7
 
10
- The package is standalone: one repository, one manifest, one lockfile, no workspace, no reference-monorepo checkout, and no dependency on an external Subagent package.
11
- Loading the package registers a private extension and four package-owned professional Agents; the `abel_dispatch` tool is registered but inactive unless a verified Design, Implement, or Diagnose stage activates it.
8
+ - `/abel-init [project-path]` — 初始化 OpenSpec 并修复 AGENTS 索引。
9
+ - `/abel-design <requirement> | --change <change_name>` — 并行只读探索,设计依赖/冲突感知的任务 DAG,经由 Gate A(行为契约)与 Gate B(技术契约)双重批准。
10
+ - `/abel-implement <change_name>` — 在已批准的 DAG 上编排任务级专业代理,委派 Red-Green-Refactor,由父代理执行验证。
11
+ - `/abel-diagnose <problem-description>` — 先验证根因,再做最小修复。
12
12
 
13
- The shared workflow rules live once in the bundled `abel-workflow` skill.
13
+ 包是独立的:单一仓库、单一清单、单一 lockfile,无 workspace、无参考仓库检出、不依赖任何外部 Subagent 包。
14
+ 加载本包会注册一个私有扩展与四个包内专业 Agent;`abel_dispatch` 工具注册但保持未激活,只有经过验证的 Design、Implement 或 Diagnose 阶段才会激活它。
14
15
 
15
- ## Install
16
+ 共享工作流规则统一收录于包内 `abel-workflow` skill。
17
+
18
+ 交互式 TUI 中,经过验证的 `abel_dispatch` run 会显示紧凑的 Subagent 角色、请求、阶段、目标、耗时和状态,并在编辑器上方临时汇总仍在排队或运行的 Agents。
19
+
20
+ 该显示只保留有限的终态摘要;不会显示子会话、工具活动、路径、模型身份、隐藏推理、完整引用或完整 diff。
21
+
22
+ print、JSON 和 RPC 模式保持原有结果与事件行为不变。
23
+
24
+ ## 安装(Install)
25
+
26
+ ### 在 pi 中快速安装(推荐)
27
+
28
+ ```sh
29
+ $ pi install npm:@abelxiaoxing/cadence
30
+ Installed npm:@abelxiaoxing/cadence
31
+ ```
32
+
33
+ 也可以使用 npm 或 bun:
16
34
 
17
35
  ```sh
18
36
  npm install -g @abelxiaoxing/cadence
19
- # or with bun: bun add -g @abelxiaoxing/cadence
37
+ # 或使用 bun:bun add -g @abelxiaoxing/cadence
20
38
  ```
21
39
 
22
- Loading paths, all verified:
40
+ 已验证的加载路径:
23
41
 
24
- - **npm package** — install `@abelxiaoxing/cadence` from the npm registry at user or project scope; Pi discovers the four prompts, four skills, the private extension, and the package-owned Agents.
25
- - **Local package directory** — point Pi at this repository's absolute or relative path (for example `./pi-abelpackages`).
26
- Pi discovers the same resources.
27
- - **Installed tarball directory** — run `bun pm pack --destination <tmp>` to produce a real tarball, install or unpack it into an isolated directory, and point Pi at that directory.
28
- The tarball file itself is never passed to Pi as a local package.
42
+ - **npm 包(npm package)** — 从 npm registry 以用户级或项目级 scope 安装 `@abelxiaoxing/cadence`;Pi 发现四个 prompts、四个 skills、私有扩展与包内 Agents。
43
+ - **本地包目录(local package directory)** — 将 Pi 指向本仓库的绝对路径或相对路径(absolute or relative path,例如 `./cadence`),Pi 发现同样的资源。
44
+ - **已安装 tarball 目录(installed tarball directory)** — 运行 `bun pm pack --destination <tmp>` 生成真实 tarball(.tgz),安装或解压到隔离目录后指向该目录;tarball 文件本身永远不会被当作本地包传给 Pi。
29
45
 
30
- The package never inspects or classifies the Pi host version, never claims a version range, and never installs from the relocated reference repository source.
46
+ 本包从不检查或分类 Pi 主机版本,不声明任何版本范围,也不从参考仓库源安装。
31
47
 
32
- ## Development
48
+ ## 开发(Development)
33
49
 
34
50
  ```sh
35
51
  bun install
36
- bun run check # syntax + type checks
52
+ bun run check # 语法 + 类型检查
37
53
  bun run lint # biome + rumdl
38
- bun run test # complete suite
39
- bun run test:target <files> # targeted tests
40
- bun run check:agents # AGENTS index validation
54
+ bun run test # 完整测试套件
55
+ bun run test:target <files> # 定向测试
56
+ bun run check:agents # AGENTS 索引校验
41
57
  ```
58
+
59
+ ## 验证(Verification)
60
+
61
+ ```sh
62
+ bun run verify # check && lint && test && pack:check(发布前全套校验)
63
+ bun run pack:check # 真实 tarball 39 成员清单校验
64
+ bun run traceability:check # 81 条 Requirement/Scenario 引用精确解析
65
+ ```
66
+
67
+ ## 许可(License)
68
+
69
+ MIT — 详见 [LICENSE](LICENSE) 与 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abelxiaoxing/cadence",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Prompts, skills, and a private orchestration extension exposing the Abel four-stage workflow (/abel-init, /abel-design, /abel-implement, /abel-diagnose) with package-owned professional Agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,11 +48,13 @@
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "*",
51
+ "@earendil-works/pi-tui": "*",
51
52
  "typebox": "*"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@biomejs/biome": "^2.4.16",
55
56
  "@earendil-works/pi-coding-agent": "0.84.1",
57
+ "@earendil-works/pi-tui": "0.84.2",
56
58
  "@types/node": "^22.15.3",
57
59
  "rumdl": "^0.2.10",
58
60
  "typescript": "^6.0.3",
@@ -10,6 +10,7 @@ argument-hint: "<requirement> | --change <change_name>"
10
10
  $ARGUMENTS
11
11
  </abel-request>
12
12
 
13
+ <!-- ABEL:PROMPT:abel-design -->
13
14
  <!-- ABEL:START -->
14
15
  # 不可协商规则(最高优先级)
15
16
 
@@ -10,6 +10,7 @@ Read the complete value inside `<abel-request>` without a second whitespace-toke
10
10
  $ARGUMENTS
11
11
  </abel-request>
12
12
 
13
+ <!-- ABEL:PROMPT:abel-diagnose -->
13
14
  The request must uniquely identify one or more existing bugs.
14
15
  If required input is missing or ambiguous, stop and ask for the missing or disambiguating information instead of guessing.
15
16
 
@@ -10,6 +10,7 @@ argument-hint: "<change_name>"
10
10
  $ARGUMENTS
11
11
  </abel-request>
12
12
 
13
+ <!-- ABEL:PROMPT:abel-implement -->
13
14
  <!-- ABEL:START -->
14
15
  **参数**:从 `<abel-request>` 读取必需的 `<change_name>`。
15
16
 
@@ -16,6 +16,20 @@ import {
16
16
  type SubmitClassification,
17
17
  } from "./submit-tool.ts";
18
18
 
19
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
20
+ signal.throwIfAborted();
21
+ return new Promise<T>((resolve, reject) => {
22
+ const onAbort = () => {
23
+ signal.removeEventListener("abort", onAbort);
24
+ reject(signal.reason);
25
+ };
26
+ signal.addEventListener("abort", onAbort, { once: true });
27
+ promise
28
+ .then(resolve, reject)
29
+ .finally(() => signal.removeEventListener("abort", onAbort));
30
+ });
31
+ }
32
+
19
33
  const ZERO_USAGE: Usage = {
20
34
  input: 0,
21
35
  output: 0,
@@ -78,6 +92,8 @@ function wrapScopedTools(roots: string[]) {
78
92
  );
79
93
  }
80
94
 
95
+ export type ChildFailureKind = "failed" | "cancelled" | "timed-out";
96
+
81
97
  export async function runChildSession(input: {
82
98
  cwd: string;
83
99
  modelRuntime: ModelRuntime;
@@ -89,6 +105,7 @@ export async function runChildSession(input: {
89
105
  output: "evidence" | "diff";
90
106
  roots: string[];
91
107
  timeoutMs: number;
108
+ signal?: AbortSignal;
92
109
  }): Promise<
93
110
  | {
94
111
  ok: true;
@@ -102,6 +119,7 @@ export async function runChildSession(input: {
102
119
  | {
103
120
  ok: false;
104
121
  error: string;
122
+ failureKind: ChildFailureKind;
105
123
  disposeCount: number;
106
124
  classification: SubmitClassification;
107
125
  }
@@ -118,10 +136,18 @@ export async function runChildSession(input: {
118
136
  let disposeCount = 0;
119
137
  const usage = new UsageAggregator();
120
138
  const abort = new AbortController();
121
- const timer = setTimeout(
122
- () => abort.abort(new Error("child phase timeout")),
123
- input.timeoutMs,
124
- );
139
+ const forwardCancellation = () =>
140
+ abort.abort(input.signal?.reason ?? new Error("child phase cancelled"));
141
+ if (input.signal?.aborted) forwardCancellation();
142
+ else
143
+ input.signal?.addEventListener("abort", forwardCancellation, {
144
+ once: true,
145
+ });
146
+ let timedOut = false;
147
+ const timer = setTimeout(() => {
148
+ timedOut = true;
149
+ abort.abort(new Error("child phase timeout"));
150
+ }, input.timeoutMs);
125
151
  let session:
126
152
  | Awaited<ReturnType<typeof createAgentSession>>["session"]
127
153
  | undefined;
@@ -160,7 +186,8 @@ export async function runChildSession(input: {
160
186
  };
161
187
  };
162
188
  try {
163
- ({ session } = await createAgentSession({
189
+ abort.signal.throwIfAborted();
190
+ const creation = createAgentSession({
164
191
  cwd: input.cwd,
165
192
  modelRuntime: input.modelRuntime,
166
193
  model: input.model,
@@ -173,7 +200,16 @@ export async function runChildSession(input: {
173
200
  compaction: { enabled: false },
174
201
  retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
175
202
  }),
176
- }));
203
+ });
204
+ try {
205
+ ({ session } = await abortable(creation, abort.signal));
206
+ } catch (error) {
207
+ void creation
208
+ .then(({ session: lateSession }) => lateSession.dispose())
209
+ .catch(() => undefined);
210
+ throw error;
211
+ }
212
+ abort.signal.throwIfAborted();
177
213
  unsubscribe = session.subscribe((event) => {
178
214
  if (event.type === "message_end" && event.message.role === "assistant") {
179
215
  usage.add(
@@ -185,6 +221,7 @@ export async function runChildSession(input: {
185
221
  const onAbort = () => void session?.abort();
186
222
  abort.signal.addEventListener("abort", onAbort, { once: true });
187
223
  try {
224
+ if (abort.signal.aborted) throw abort.signal.reason;
188
225
  await Promise.race([
189
226
  session.prompt(input.systemPrompt, { expandPromptTemplates: false }),
190
227
  new Promise<never>((_, reject) =>
@@ -206,6 +243,7 @@ export async function runChildSession(input: {
206
243
  return {
207
244
  ok: false,
208
245
  error: "child did not retain exactly one structural submission",
246
+ failureKind: "failed",
209
247
  disposeCount,
210
248
  classification,
211
249
  };
@@ -223,6 +261,7 @@ export async function runChildSession(input: {
223
261
  return {
224
262
  ok: false,
225
263
  error: "final assistant message is not one structural submit",
264
+ failureKind: "failed",
226
265
  disposeCount,
227
266
  classification,
228
267
  };
@@ -239,14 +278,22 @@ export async function runChildSession(input: {
239
278
  };
240
279
  } catch (error) {
241
280
  disposeOnce();
281
+ const failureKind: ChildFailureKind = timedOut
282
+ ? "timed-out"
283
+ : input.signal !== undefined &&
284
+ (input.signal.aborted || abort.signal.reason === input.signal.reason)
285
+ ? "cancelled"
286
+ : "failed";
242
287
  return {
243
288
  ok: false,
244
289
  error: (error as Error).message,
290
+ failureKind,
245
291
  disposeCount,
246
292
  classification: classifySession(),
247
293
  };
248
294
  } finally {
249
295
  clearTimeout(timer);
296
+ input.signal?.removeEventListener("abort", forwardCancellation);
250
297
  unsubscribe?.();
251
298
  disposeOnce();
252
299
  }
package/src/index.ts CHANGED
@@ -3,15 +3,97 @@
3
3
  // the active set at session start. Eligible-stage activation is wired by the
4
4
  // workflow routing (abel-design/implement/diagnose provenance) in the prompts
5
5
  // integration; abel-init and ordinary prompts never activate dispatch.
6
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
- import { deactivateTool } from "./activation.ts";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import type {
9
+ AgentToolResult,
10
+ AgentToolUpdateCallback,
11
+ ExtensionAPI,
12
+ ExtensionContext,
13
+ Theme,
14
+ ToolRenderResultOptions,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import { type Activation, activateTool, deactivateTool } from "./activation.ts";
8
17
  import { ACTIONS } from "./contracts.ts";
9
18
  import { Runtime } from "./runtime.ts";
19
+ import {
20
+ ACTIVITY_DETAILS_KEY,
21
+ ActivityController,
22
+ renderActivityCall,
23
+ renderActivityResult,
24
+ } from "./subagent-activity.ts";
10
25
 
11
26
  export const DISPATCH_TOOL = "abel_dispatch";
12
27
 
28
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
+ const ELIGIBLE_PROMPTS = [
30
+ "abel-design",
31
+ "abel-implement",
32
+ "abel-diagnose",
33
+ ] as const;
34
+ type EligiblePrompt = (typeof ELIGIBLE_PROMPTS)[number];
35
+
36
+ function invokedPrompt(text: string): EligiblePrompt | undefined {
37
+ const name = text.match(/^\/([^\s]+)(?:\s|$)/)?.[1];
38
+ return ELIGIBLE_PROMPTS.find((candidate) => candidate === name);
39
+ }
40
+
41
+ function promptMarker(name: EligiblePrompt): string {
42
+ return `<!-- ABEL:PROMPT:${name} -->`;
43
+ }
44
+
45
+ function hasPackageProvenance(pi: ExtensionAPI, name: EligiblePrompt): boolean {
46
+ const commands = pi
47
+ .getCommands()
48
+ .filter((candidate) => candidate.name === name);
49
+ return (
50
+ commands.length === 1 &&
51
+ commands[0]?.source === "prompt" &&
52
+ commands[0].sourceInfo.origin === "package" &&
53
+ commands[0].sourceInfo.baseDir === PACKAGE_ROOT &&
54
+ commands[0].sourceInfo.path === join(PACKAGE_ROOT, "prompts", `${name}.md`)
55
+ );
56
+ }
57
+
58
+ function hasExpandedPromptMarker(
59
+ prompt: string,
60
+ name: EligiblePrompt,
61
+ ): boolean {
62
+ const requestEnd = prompt.lastIndexOf("</abel-request>");
63
+ if (requestEnd < 0) return false;
64
+ const body = prompt.slice(requestEnd + "</abel-request>".length);
65
+ const marker = promptMarker(name);
66
+ return (
67
+ body.includes(marker) && body.indexOf(marker) === body.lastIndexOf(marker)
68
+ );
69
+ }
70
+
71
+ function activateDispatcher(
72
+ pi: ExtensionAPI,
73
+ activation: Activation,
74
+ name: EligiblePrompt,
75
+ prompt: string,
76
+ ): void {
77
+ if (
78
+ !hasPackageProvenance(pi, name) ||
79
+ !hasExpandedPromptMarker(prompt, name)
80
+ ) {
81
+ return;
82
+ }
83
+ if (!activation.isActive()) {
84
+ if (activation.state !== "inactive") return;
85
+ activation.request();
86
+ activation.activate();
87
+ }
88
+ const active = pi.getActiveTools();
89
+ if (!active.includes(DISPATCH_TOOL)) {
90
+ pi.setActiveTools(activateTool(active, DISPATCH_TOOL));
91
+ }
92
+ }
93
+
13
94
  export default function register(pi: ExtensionAPI): void {
14
95
  const runtime = new Runtime();
96
+ const activity = new ActivityController();
15
97
 
16
98
  pi.registerTool({
17
99
  name: DISPATCH_TOOL,
@@ -34,30 +116,78 @@ export default function register(pi: ExtensionAPI): void {
34
116
  required: ["action"],
35
117
  },
36
118
  async execute(
37
- _toolCallId: string,
119
+ toolCallId: string,
38
120
  params: { action?: string; request?: unknown; resultId?: string },
39
- _signal: AbortSignal | undefined,
40
- _onUpdate: unknown,
41
- ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
121
+ signal: AbortSignal | undefined,
122
+ onUpdate: AgentToolUpdateCallback<unknown> | undefined,
123
+ ctx: ExtensionContext,
42
124
  ) {
43
125
  const action = typeof params?.action === "string" ? params.action : "";
44
- const result = await runtime.execute(action, params, ctx);
126
+ const validRun =
127
+ action === "run" && runtime.validateRequest(params.request).ok;
128
+ const tuiRun = ctx.mode === "tui" && validRun;
129
+ const result = tuiRun
130
+ ? await runtime.execute(
131
+ action,
132
+ params,
133
+ ctx,
134
+ signal,
135
+ activity.observe(
136
+ toolCallId,
137
+ onUpdate as ((result: unknown) => void) | undefined,
138
+ ),
139
+ )
140
+ : await runtime.execute(action, params, ctx, signal);
141
+ const display = tuiRun
142
+ ? activity.finalize(toolCallId, result)
143
+ : undefined;
144
+ const details = display
145
+ ? { ...result, [ACTIVITY_DETAILS_KEY]: display }
146
+ : result;
45
147
  if (result.ok) {
46
148
  return {
47
149
  content: [{ type: "text", text: JSON.stringify(result) }],
48
- details: result,
150
+ details,
49
151
  usage: result.usage,
50
152
  };
51
153
  }
52
154
  return {
53
155
  content: [{ type: "text", text: result.error }],
54
- details: result,
156
+ details,
55
157
  isError: true,
56
158
  };
57
159
  },
160
+ renderCall(args: unknown, theme: Theme, _context: unknown) {
161
+ return renderActivityCall(args, theme);
162
+ },
163
+ renderResult(
164
+ result: AgentToolResult<unknown>,
165
+ options: ToolRenderResultOptions,
166
+ theme: Theme,
167
+ _context: unknown,
168
+ ) {
169
+ return renderActivityResult(result, options, theme);
170
+ },
58
171
  } as never);
59
172
 
60
- pi.on("session_start", () => {
173
+ let pendingPrompt: EligiblePrompt | undefined;
174
+
175
+ pi.on("input", (event) => {
176
+ pendingPrompt = invokedPrompt(event.text);
177
+ return { action: "continue" };
178
+ });
179
+
180
+ pi.on("before_agent_start", (event) => {
181
+ const prompt = pendingPrompt;
182
+ pendingPrompt = undefined;
183
+ if (prompt)
184
+ activateDispatcher(pi, runtime.activation, prompt, event.prompt);
185
+ });
186
+
187
+ pi.on("session_start", (_event, ctx) => {
188
+ pendingPrompt = undefined;
189
+ activity.detach();
190
+ if (ctx.mode === "tui") activity.attach(ctx.ui);
61
191
  // Restore the default: dispatch is inactive unless a verified stage
62
192
  // reactivates it through the activation helpers.
63
193
  const active = pi.getActiveTools();
@@ -66,8 +196,10 @@ export default function register(pi: ExtensionAPI): void {
66
196
  }
67
197
  });
68
198
 
69
- pi.on("session_shutdown", () => {
70
- runtime.drain();
199
+ pi.on("session_shutdown", async () => {
200
+ activity.detach();
201
+ await runtime.drain();
202
+ activity.clear();
71
203
  const active = pi.getActiveTools();
72
204
  if (active.includes(DISPATCH_TOOL)) {
73
205
  pi.setActiveTools(deactivateTool(active, DISPATCH_TOOL));
@@ -10,12 +10,19 @@ import {
10
10
 
11
11
  export async function runtimeForProvider(
12
12
  provider: Provider,
13
+ signal?: AbortSignal,
13
14
  ): Promise<ModelRuntime> {
14
- const runtime = await ModelRuntime.create({
15
- credentials: new InMemoryCredentialStore(),
16
- modelsPath: null,
17
- refreshOnCreate: false,
18
- });
15
+ signal?.throwIfAborted();
16
+ const runtime = await abortable(
17
+ ModelRuntime.create({
18
+ credentials: new InMemoryCredentialStore(),
19
+ modelsPath: null,
20
+ refreshOnCreate: false,
21
+ signal,
22
+ }),
23
+ signal,
24
+ );
25
+ signal?.throwIfAborted();
19
26
  runtime.registerNativeProvider(provider);
20
27
  return runtime;
21
28
  }
@@ -69,17 +76,39 @@ export function phaseProvider(
69
76
 
70
77
  export async function runtimeFromContext(
71
78
  ctx: Pick<ExtensionContext, "model" | "modelRegistry">,
79
+ signal?: AbortSignal,
72
80
  ): Promise<{ modelRuntime: ModelRuntime; model: Model<string> }> {
81
+ signal?.throwIfAborted();
73
82
  if (!ctx.model) throw new Error("parent model is unavailable");
74
83
  const parent = ctx.modelRegistry.getProvider(ctx.model.provider);
75
84
  if (!parent) throw new Error("parent Provider is unavailable");
76
- const resolved = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
85
+ const resolved = await abortable(
86
+ ctx.modelRegistry.getApiKeyAndHeaders(ctx.model),
87
+ signal,
88
+ );
77
89
  if (!resolved.ok) throw new Error(resolved.error);
90
+ signal?.throwIfAborted();
78
91
  const provider = phaseProvider(parent, resolved);
79
- const modelRuntime = await runtimeForProvider(provider);
92
+ const modelRuntime = await runtimeForProvider(provider, signal);
93
+ signal?.throwIfAborted();
80
94
  const model = {
81
95
  ...ctx.model,
82
96
  baseUrl: resolved.baseUrl ?? ctx.model.baseUrl,
83
97
  } as Model<string>;
84
98
  return { modelRuntime, model };
85
99
  }
100
+
101
+ function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
102
+ if (!signal) return promise;
103
+ signal.throwIfAborted();
104
+ return new Promise<T>((resolve, reject) => {
105
+ const onAbort = () => {
106
+ signal.removeEventListener("abort", onAbort);
107
+ reject(signal.reason);
108
+ };
109
+ signal.addEventListener("abort", onAbort, { once: true });
110
+ promise
111
+ .then(resolve, reject)
112
+ .finally(() => signal.removeEventListener("abort", onAbort));
113
+ });
114
+ }