@abelxiaoxing/cadence 1.0.0 → 1.0.2

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/src/index.ts CHANGED
@@ -3,15 +3,106 @@
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";
18
+ import { ParentPayloadBridge } from "./parent-payload-bridge.ts";
9
19
  import { Runtime } from "./runtime.ts";
20
+ import {
21
+ ACTIVITY_DETAILS_KEY,
22
+ ActivityController,
23
+ renderActivityCall,
24
+ renderActivityResult,
25
+ } from "./subagent-activity.ts";
10
26
 
11
27
  export const DISPATCH_TOOL = "abel_dispatch";
12
28
 
29
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
30
+ const ELIGIBLE_PROMPTS = [
31
+ "abel-design",
32
+ "abel-implement",
33
+ "abel-diagnose",
34
+ ] as const;
35
+ type EligiblePrompt = (typeof ELIGIBLE_PROMPTS)[number];
36
+
37
+ function invokedPrompt(text: string): EligiblePrompt | undefined {
38
+ const name = text.match(/^\/([^\s]+)(?:\s|$)/)?.[1];
39
+ return ELIGIBLE_PROMPTS.find((candidate) => candidate === name);
40
+ }
41
+
42
+ function promptMarker(name: EligiblePrompt): string {
43
+ return `<!-- ABEL:PROMPT:${name} -->`;
44
+ }
45
+
46
+ function hasPackageProvenance(pi: ExtensionAPI, name: EligiblePrompt): boolean {
47
+ const commands = pi
48
+ .getCommands()
49
+ .filter((candidate) => candidate.name === name);
50
+ return (
51
+ commands.length === 1 &&
52
+ commands[0]?.source === "prompt" &&
53
+ commands[0].sourceInfo.origin === "package" &&
54
+ commands[0].sourceInfo.baseDir === PACKAGE_ROOT &&
55
+ commands[0].sourceInfo.path === join(PACKAGE_ROOT, "prompts", `${name}.md`)
56
+ );
57
+ }
58
+
59
+ function hasExpandedPromptMarker(
60
+ prompt: string,
61
+ name: EligiblePrompt,
62
+ ): boolean {
63
+ const requestEnd = prompt.lastIndexOf("</abel-request>");
64
+ if (requestEnd < 0) return false;
65
+ const body = prompt.slice(requestEnd + "</abel-request>".length);
66
+ const marker = promptMarker(name);
67
+ return (
68
+ body.includes(marker) && body.indexOf(marker) === body.lastIndexOf(marker)
69
+ );
70
+ }
71
+
72
+ function isVerifiedStageInvocation(
73
+ pi: ExtensionAPI,
74
+ activation: Activation,
75
+ name: EligiblePrompt,
76
+ prompt: string,
77
+ ): boolean {
78
+ return (
79
+ hasPackageProvenance(pi, name) &&
80
+ hasExpandedPromptMarker(prompt, name) &&
81
+ (activation.isActive() || activation.state === "inactive")
82
+ );
83
+ }
84
+
85
+ function activateDispatcher(
86
+ pi: ExtensionAPI,
87
+ activation: Activation,
88
+ name: EligiblePrompt,
89
+ prompt: string,
90
+ ): void {
91
+ if (!isVerifiedStageInvocation(pi, activation, name, prompt)) return;
92
+ if (!activation.isActive()) {
93
+ activation.request();
94
+ activation.activate();
95
+ }
96
+ const active = pi.getActiveTools();
97
+ if (!active.includes(DISPATCH_TOOL)) {
98
+ pi.setActiveTools(activateTool(active, DISPATCH_TOOL));
99
+ }
100
+ }
101
+
13
102
  export default function register(pi: ExtensionAPI): void {
14
- const runtime = new Runtime();
103
+ const parentPayloadBridge = new ParentPayloadBridge();
104
+ const runtime = new Runtime({ parentPayloadBridge });
105
+ const activity = new ActivityController();
15
106
 
16
107
  pi.registerTool({
17
108
  name: DISPATCH_TOOL,
@@ -34,30 +125,101 @@ export default function register(pi: ExtensionAPI): void {
34
125
  required: ["action"],
35
126
  },
36
127
  async execute(
37
- _toolCallId: string,
128
+ toolCallId: string,
38
129
  params: { action?: string; request?: unknown; resultId?: string },
39
- _signal: AbortSignal | undefined,
40
- _onUpdate: unknown,
41
- ctx: import("@earendil-works/pi-coding-agent").ExtensionContext,
130
+ signal: AbortSignal | undefined,
131
+ onUpdate: AgentToolUpdateCallback<unknown> | undefined,
132
+ ctx: ExtensionContext,
42
133
  ) {
43
134
  const action = typeof params?.action === "string" ? params.action : "";
44
- const result = await runtime.execute(action, params, ctx);
135
+ const validRun =
136
+ action === "run" && runtime.validateRequest(params.request).ok;
137
+ const tuiRun = ctx.mode === "tui" && validRun;
138
+ const result = tuiRun
139
+ ? await runtime.execute(
140
+ action,
141
+ params,
142
+ ctx,
143
+ signal,
144
+ activity.observe(
145
+ toolCallId,
146
+ onUpdate as ((result: unknown) => void) | undefined,
147
+ ),
148
+ )
149
+ : await runtime.execute(action, params, ctx, signal);
150
+ const display = tuiRun
151
+ ? activity.finalize(toolCallId, result)
152
+ : undefined;
153
+ const details = display
154
+ ? { ...result, [ACTIVITY_DETAILS_KEY]: display }
155
+ : result;
45
156
  if (result.ok) {
46
157
  return {
47
158
  content: [{ type: "text", text: JSON.stringify(result) }],
48
- details: result,
159
+ details,
49
160
  usage: result.usage,
50
161
  };
51
162
  }
52
163
  return {
53
- content: [{ type: "text", text: result.error }],
54
- details: result,
164
+ content: [{ type: "text", text: JSON.stringify(result) }],
165
+ details,
55
166
  isError: true,
56
167
  };
57
168
  },
169
+ renderCall(args: unknown, theme: Theme, _context: unknown) {
170
+ return renderActivityCall(args, theme);
171
+ },
172
+ renderResult(
173
+ result: AgentToolResult<unknown>,
174
+ options: ToolRenderResultOptions,
175
+ theme: Theme,
176
+ _context: unknown,
177
+ ) {
178
+ return renderActivityResult(result, options, theme);
179
+ },
58
180
  } as never);
59
181
 
60
- pi.on("session_start", () => {
182
+ let pendingPrompt: EligiblePrompt | undefined;
183
+
184
+ pi.on("input", (event) => {
185
+ pendingPrompt = invokedPrompt(event.text);
186
+ return { action: "continue" };
187
+ });
188
+
189
+ pi.on("before_agent_start", (event, ctx) => {
190
+ const prompt = pendingPrompt;
191
+ pendingPrompt = undefined;
192
+ if (
193
+ prompt &&
194
+ isVerifiedStageInvocation(pi, runtime.activation, prompt, event.prompt)
195
+ ) {
196
+ const sessionId = ctx.sessionManager?.getSessionId?.();
197
+ if (typeof sessionId === "string") {
198
+ parentPayloadBridge.beginSession(sessionId);
199
+ } else {
200
+ parentPayloadBridge.clear();
201
+ }
202
+ }
203
+ if (ctx.model) {
204
+ parentPayloadBridge.install(ctx.model, ctx.modelRegistry);
205
+ }
206
+ if (prompt)
207
+ activateDispatcher(pi, runtime.activation, prompt, event.prompt);
208
+ });
209
+
210
+ pi.on("session_start", (_event, ctx) => {
211
+ const sessionId = ctx.sessionManager?.getSessionId?.();
212
+ if (typeof sessionId === "string") {
213
+ parentPayloadBridge.beginSession(sessionId);
214
+ if (ctx.model) {
215
+ parentPayloadBridge.install(ctx.model, ctx.modelRegistry);
216
+ }
217
+ } else {
218
+ parentPayloadBridge.clear();
219
+ }
220
+ pendingPrompt = undefined;
221
+ activity.detach();
222
+ if (ctx.mode === "tui") activity.attach(ctx.ui);
61
223
  // Restore the default: dispatch is inactive unless a verified stage
62
224
  // reactivates it through the activation helpers.
63
225
  const active = pi.getActiveTools();
@@ -66,8 +228,23 @@ export default function register(pi: ExtensionAPI): void {
66
228
  }
67
229
  });
68
230
 
69
- pi.on("session_shutdown", () => {
70
- runtime.drain();
231
+ pi.on("model_select", (event, ctx) => {
232
+ const sessionId = ctx.sessionManager?.getSessionId?.();
233
+ if (typeof sessionId !== "string") {
234
+ parentPayloadBridge.clear();
235
+ return;
236
+ }
237
+ parentPayloadBridge.beginSession(sessionId);
238
+ const model = event.model ?? ctx.model;
239
+ if (model) {
240
+ parentPayloadBridge.install(model, ctx.modelRegistry);
241
+ }
242
+ });
243
+
244
+ pi.on("session_shutdown", async () => {
245
+ activity.detach();
246
+ await runtime.drain();
247
+ activity.clear();
71
248
  const active = pi.getActiveTools();
72
249
  if (active.includes(DISPATCH_TOOL)) {
73
250
  pi.setActiveTools(deactivateTool(active, DISPATCH_TOOL));
@@ -0,0 +1,442 @@
1
+ import {
2
+ type AssistantMessage,
3
+ type AssistantMessageEventStream,
4
+ type Context,
5
+ createAssistantMessageEventStream,
6
+ type Model,
7
+ type Provider,
8
+ } from "@earendil-works/pi-ai";
9
+
10
+ export type ParentPayloadCallback = (
11
+ payload: unknown,
12
+ model: Model<string>,
13
+ ) => unknown | undefined | Promise<unknown | undefined>;
14
+
15
+ export interface ParentModelKey {
16
+ provider: string;
17
+ id: string;
18
+ api: string;
19
+ baseUrl: string;
20
+ }
21
+
22
+ export interface ParentPayloadCapture {
23
+ readonly generation: number;
24
+ readonly sessionId: string;
25
+ readonly modelKey: Readonly<ParentModelKey>;
26
+ readonly delegate: Provider;
27
+ readonly onPayload: ParentPayloadCallback;
28
+ }
29
+
30
+ export interface ParentProviderRegistry {
31
+ getProvider(provider: string): Provider | undefined;
32
+ registerProvider(provider: Provider): void;
33
+ }
34
+
35
+ interface GenerationState {
36
+ readonly generation: number;
37
+ readonly sessionId: string;
38
+ active: boolean;
39
+ readonly ready: Map<string, ParentPayloadCapture>;
40
+ readonly installed: Map<string, Provider>;
41
+ tail: Promise<void>;
42
+ }
43
+
44
+ interface ProvisionalCapture {
45
+ readonly state: GenerationState;
46
+ readonly modelKey: ParentModelKey;
47
+ readonly delegate: Provider;
48
+ readonly onPayload: ParentPayloadCallback;
49
+ }
50
+
51
+ const PROVIDER_WRAPPER = Symbol("cadence-parent-payload-provider");
52
+
53
+ function streamFailure(model: Model<string>): AssistantMessage {
54
+ return {
55
+ role: "assistant",
56
+ content: [],
57
+ api: model.api,
58
+ provider: model.provider,
59
+ model: model.id,
60
+ usage: {
61
+ input: 0,
62
+ output: 0,
63
+ cacheRead: 0,
64
+ cacheWrite: 0,
65
+ totalTokens: 0,
66
+ cost: {
67
+ input: 0,
68
+ output: 0,
69
+ cacheRead: 0,
70
+ cacheWrite: 0,
71
+ total: 0,
72
+ },
73
+ },
74
+ stopReason: "error",
75
+ errorMessage: "parent provider stream failed",
76
+ timestamp: Date.now(),
77
+ };
78
+ }
79
+
80
+ function abortable<T>(
81
+ value: T | PromiseLike<T>,
82
+ signal?: AbortSignal,
83
+ ): Promise<T> {
84
+ if (!signal) return Promise.resolve(value);
85
+ signal.throwIfAborted();
86
+ return new Promise<T>((resolve, reject) => {
87
+ let settled = false;
88
+ const finish = (callback: () => void) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ signal.removeEventListener("abort", onAbort);
92
+ callback();
93
+ };
94
+ const onAbort = () => {
95
+ try {
96
+ signal.throwIfAborted();
97
+ } catch (error) {
98
+ finish(() => reject(error));
99
+ }
100
+ };
101
+ signal.addEventListener("abort", onAbort, { once: true });
102
+ if (signal.aborted) onAbort();
103
+ Promise.resolve(value).then(
104
+ (result) => finish(() => resolve(result)),
105
+ (error) => finish(() => reject(error)),
106
+ );
107
+ });
108
+ }
109
+
110
+ function modelKeyFor(model: Model<string>): ParentModelKey {
111
+ return {
112
+ provider: model.provider,
113
+ id: model.id,
114
+ api: model.api,
115
+ baseUrl: model.baseUrl,
116
+ };
117
+ }
118
+
119
+ function modelKeyId(modelKey: Readonly<ParentModelKey>): string {
120
+ return JSON.stringify([
121
+ modelKey.provider,
122
+ modelKey.id,
123
+ modelKey.api,
124
+ modelKey.baseUrl,
125
+ ]);
126
+ }
127
+
128
+ function sameModelKey(
129
+ left: Readonly<ParentModelKey>,
130
+ right: Readonly<ParentModelKey>,
131
+ ): boolean {
132
+ return (
133
+ left.provider === right.provider &&
134
+ left.id === right.id &&
135
+ left.api === right.api &&
136
+ left.baseUrl === right.baseUrl
137
+ );
138
+ }
139
+
140
+ function requireSafePayload(
141
+ payload: unknown,
142
+ ): asserts payload is Record<string, unknown> {
143
+ if (
144
+ typeof payload !== "object" ||
145
+ payload === null ||
146
+ Array.isArray(payload)
147
+ ) {
148
+ throw new Error("parent payload bridge produced an unsafe payload");
149
+ }
150
+ }
151
+
152
+ export class ParentPayloadBridge {
153
+ private nextGeneration = 0;
154
+ private state: GenerationState | undefined;
155
+ private readonly captureStates = new WeakMap<
156
+ ParentPayloadCapture,
157
+ GenerationState
158
+ >();
159
+
160
+ beginSession(sessionId: string): number {
161
+ this.invalidate();
162
+ const state: GenerationState = {
163
+ generation: ++this.nextGeneration,
164
+ sessionId,
165
+ active: true,
166
+ ready: new Map(),
167
+ installed: new Map(),
168
+ tail: Promise.resolve(),
169
+ };
170
+ this.state = state;
171
+ return state.generation;
172
+ }
173
+
174
+ clear(): void {
175
+ this.invalidate();
176
+ this.nextGeneration++;
177
+ this.state = undefined;
178
+ }
179
+
180
+ install(
181
+ model: Model<string>,
182
+ registry: ParentProviderRegistry,
183
+ ): Provider | undefined {
184
+ const current = registry.getProvider(model.provider);
185
+ if (!current) return undefined;
186
+ const delegate = this.unwrapProvider(current);
187
+ const state = this.state;
188
+ if (state?.active) {
189
+ const key = modelKeyId(modelKeyFor(model));
190
+ state.installed.set(key, delegate);
191
+ const ready = state.ready.get(key);
192
+ if (ready && ready.delegate !== delegate) {
193
+ state.ready.delete(key);
194
+ }
195
+ }
196
+ const wrapped = this.wrapProvider(delegate);
197
+ registry.registerProvider(wrapped);
198
+ return wrapped;
199
+ }
200
+
201
+ capture(
202
+ modelKey: ParentModelKey,
203
+ registry?: Pick<ParentProviderRegistry, "getProvider">,
204
+ ): ParentPayloadCapture | undefined {
205
+ const state = this.state;
206
+ if (!state?.active) return undefined;
207
+ const capture = state.ready.get(modelKeyId(modelKey));
208
+ if (!capture || !sameModelKey(capture.modelKey, modelKey)) {
209
+ return undefined;
210
+ }
211
+ if (registry) {
212
+ const current = registry.getProvider(modelKey.provider);
213
+ if (!current || this.unwrapProvider(current) !== capture.delegate) {
214
+ return undefined;
215
+ }
216
+ }
217
+ return capture;
218
+ }
219
+
220
+ wrapProvider(delegate: Provider): Provider {
221
+ const original = this.unwrapProvider(delegate);
222
+ const stream = (
223
+ model: Model<string>,
224
+ context: Context,
225
+ options?: unknown,
226
+ ) => this.forwardProvider(original, "stream", model, context, options);
227
+ const streamSimple = (
228
+ model: Model<string>,
229
+ context: Context,
230
+ options?: unknown,
231
+ ) =>
232
+ this.forwardProvider(original, "streamSimple", model, context, options);
233
+ const boundMethods = new Map<
234
+ PropertyKey,
235
+ {
236
+ source: (...args: never[]) => unknown;
237
+ bound: (...args: never[]) => unknown;
238
+ }
239
+ >();
240
+
241
+ return new Proxy(original, {
242
+ get(target, property) {
243
+ if (property === PROVIDER_WRAPPER) return original;
244
+ if (property === "stream") return stream;
245
+ if (property === "streamSimple") return streamSimple;
246
+ const value = Reflect.get(target, property, target) as unknown;
247
+ if (typeof value !== "function") return value;
248
+ const current = boundMethods.get(property);
249
+ if (current?.source === value) return current.bound;
250
+ const source = value as (...args: never[]) => unknown;
251
+ const bound = source.bind(target);
252
+ boundMethods.set(property, { source, bound });
253
+ return bound;
254
+ },
255
+ set(target, property, value) {
256
+ return Reflect.set(target, property, value, target);
257
+ },
258
+ });
259
+ }
260
+
261
+ async composePayload(
262
+ capture: ParentPayloadCapture | undefined,
263
+ payload: unknown,
264
+ model: Model<string>,
265
+ childOnPayload?: ParentPayloadCallback,
266
+ signal?: AbortSignal,
267
+ ): Promise<Record<string, unknown>> {
268
+ if (!capture) throw new Error("parent payload bridge is unavailable");
269
+ const requestedKey = modelKeyFor(model);
270
+ const state = this.requireCurrentCapture(capture, requestedKey);
271
+ signal?.throwIfAborted();
272
+
273
+ let current = payload;
274
+ if (childOnPayload) {
275
+ const childResult = await abortable(
276
+ childOnPayload(current, model),
277
+ signal,
278
+ );
279
+ if (childResult !== undefined) current = childResult;
280
+ }
281
+ requireSafePayload(current);
282
+ signal?.throwIfAborted();
283
+ this.requireCurrentCapture(capture, requestedKey);
284
+
285
+ const predecessor = state.tail;
286
+ let release = () => {};
287
+ const completed = new Promise<void>((resolve) => {
288
+ release = () => resolve();
289
+ });
290
+ state.tail = predecessor.then(
291
+ () => completed,
292
+ () => completed,
293
+ );
294
+ let parentSettlement: Promise<void> | undefined;
295
+
296
+ try {
297
+ await abortable(predecessor, signal);
298
+ signal?.throwIfAborted();
299
+ this.requireCurrentCapture(capture, requestedKey);
300
+
301
+ const parentCall = Promise.resolve(capture.onPayload(current, model));
302
+ parentSettlement = parentCall.then(
303
+ () => undefined,
304
+ () => undefined,
305
+ );
306
+ const parentResult = await abortable(parentCall, signal);
307
+ if (parentResult !== undefined) current = parentResult;
308
+ requireSafePayload(current);
309
+
310
+ let finalPayload: Record<string, unknown> = current;
311
+ if (model.api === "openai-responses") {
312
+ finalPayload = { ...current };
313
+ delete finalPayload.max_output_tokens;
314
+ }
315
+
316
+ signal?.throwIfAborted();
317
+ this.requireCurrentCapture(capture, requestedKey);
318
+ return finalPayload;
319
+ } finally {
320
+ if (parentSettlement) void parentSettlement.then(release);
321
+ else release();
322
+ }
323
+ }
324
+
325
+ private forwardProvider(
326
+ delegate: Provider,
327
+ method: "stream" | "streamSimple",
328
+ model: Model<string>,
329
+ context: Context,
330
+ options?: unknown,
331
+ ): AssistantMessageEventStream {
332
+ const state = this.state;
333
+ const candidate = (options as { onPayload?: unknown } | null | undefined)
334
+ ?.onPayload;
335
+ const onPayload =
336
+ typeof candidate === "function"
337
+ ? (candidate as ParentPayloadCallback)
338
+ : undefined;
339
+ const provisional: ProvisionalCapture | undefined =
340
+ state?.active && onPayload
341
+ ? {
342
+ state,
343
+ modelKey: modelKeyFor(model),
344
+ delegate,
345
+ onPayload,
346
+ }
347
+ : undefined;
348
+ const source =
349
+ method === "stream"
350
+ ? delegate.stream(model, context, options as never)
351
+ : delegate.streamSimple(model, context, options as never);
352
+ const output = createAssistantMessageEventStream();
353
+ void this.mirrorStream(source, output, model, provisional);
354
+ return output;
355
+ }
356
+
357
+ private async mirrorStream(
358
+ source: AssistantMessageEventStream,
359
+ output: AssistantMessageEventStream,
360
+ model: Model<string>,
361
+ provisional: ProvisionalCapture | undefined,
362
+ ): Promise<void> {
363
+ let successful = false;
364
+ try {
365
+ for await (const event of source) {
366
+ if (event.type === "done") successful = true;
367
+ output.push(event);
368
+ }
369
+ if (successful && provisional) this.commitCapture(provisional);
370
+ } catch {
371
+ const error = streamFailure(model);
372
+ output.push({ type: "error", reason: "error", error });
373
+ } finally {
374
+ output.end();
375
+ }
376
+ }
377
+
378
+ private commitCapture(provisional: ProvisionalCapture): void {
379
+ const { state } = provisional;
380
+ if (!state.active || this.state !== state) return;
381
+ const key = modelKeyId(provisional.modelKey);
382
+ if (
383
+ state.installed.has(key) &&
384
+ state.installed.get(key) !== provisional.delegate
385
+ ) {
386
+ return;
387
+ }
388
+ const capture: ParentPayloadCapture = Object.freeze({
389
+ generation: state.generation,
390
+ sessionId: state.sessionId,
391
+ modelKey: Object.freeze({ ...provisional.modelKey }),
392
+ delegate: provisional.delegate,
393
+ onPayload: provisional.onPayload,
394
+ });
395
+ state.ready.set(key, capture);
396
+ this.captureStates.set(capture, state);
397
+ }
398
+
399
+ private requireCurrentCapture(
400
+ capture: ParentPayloadCapture | undefined,
401
+ modelKey: ParentModelKey,
402
+ ): GenerationState {
403
+ const state = this.state;
404
+ if (
405
+ !capture ||
406
+ !state?.active ||
407
+ this.captureStates.get(capture) !== state ||
408
+ capture.generation !== state.generation ||
409
+ capture.sessionId !== state.sessionId ||
410
+ !sameModelKey(capture.modelKey, modelKey) ||
411
+ state.ready.get(modelKeyId(modelKey)) !== capture
412
+ ) {
413
+ throw new Error("parent payload bridge is unavailable");
414
+ }
415
+ return state;
416
+ }
417
+
418
+ private unwrapProvider(provider: Provider): Provider {
419
+ let current = provider;
420
+ const seen = new Set<Provider>();
421
+ while (!seen.has(current)) {
422
+ seen.add(current);
423
+ const candidate = Reflect.get(current, PROVIDER_WRAPPER) as unknown;
424
+ if (
425
+ typeof candidate !== "object" ||
426
+ candidate === null ||
427
+ seen.has(candidate as Provider)
428
+ ) {
429
+ return current;
430
+ }
431
+ current = candidate as Provider;
432
+ }
433
+ return current;
434
+ }
435
+
436
+ private invalidate(): void {
437
+ if (!this.state) return;
438
+ this.state.active = false;
439
+ this.state.ready.clear();
440
+ this.state.installed.clear();
441
+ }
442
+ }