@jopqior/pi-subagents-model-selector 0.1.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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/package.json +68 -0
- package/src/index.ts +47 -0
- package/src/model-selector.ts +186 -0
- package/src/selection-queue.ts +110 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jopqior
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# @jopqior/pi-subagents-model-selector
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@jopqior/pi-subagents-model-selector) [](https://github.com/Jopqior/gotgenes-pi-packages/actions/workflows/ci.yml) [](https://opensource.org/licenses/MIT) [](https://www.typescriptlang.org/) [](https://pnpm.io/) [](https://pi.mariozechner.at/)
|
|
4
|
+
|
|
5
|
+
Ask for model and thinking before every new in-process `@jopqior/pi-subagents` child session.
|
|
6
|
+
|
|
7
|
+
Installing or upgrading the core alone does not change selection.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Load **after** `@jopqior/pi-subagents`.
|
|
12
|
+
Pi loads packages in the order they are listed in `.pi/settings.json`, and this extension registers its provider with the subagents service at load time — so the core must load first.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pi install npm:@jopqior/pi-subagents
|
|
16
|
+
pi install npm:@jopqior/pi-subagents-model-selector
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then list those two `npm:` sources in that order:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"packages": [
|
|
24
|
+
"npm:@jopqior/pi-subagents",
|
|
25
|
+
"npm:@jopqior/pi-subagents-model-selector"
|
|
26
|
+
]
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or load from a checkout:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"packages": [
|
|
35
|
+
"../packages/pi-subagents",
|
|
36
|
+
"../packages/pi-subagents-model-selector"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
If the core is missing, failed to load, or lacks `registerSpawnSelectionProvider`, this extension throws a configuration error at initialization and does not activate.
|
|
42
|
+
|
|
43
|
+
## Behavior
|
|
44
|
+
|
|
45
|
+
Every **new** run in an enabled root's in-process tree asks twice: model, then thinking.
|
|
46
|
+
Foreground tool calls, background tool calls, and `SubagentsService.spawn()` all go through the same gate.
|
|
47
|
+
|
|
48
|
+
The operator's pair is applied after ordinary call and config resolution.
|
|
49
|
+
It overrides defaults, explicit `model`/`thinking` arguments, and `locked:` values for those two fields only.
|
|
50
|
+
Other locked fields are unchanged.
|
|
51
|
+
|
|
52
|
+
The catalogue is every authenticated available model of the session whose manager is spawning — not a process-global list, and not `/scoped-models`.
|
|
53
|
+
A model that supports only `off` still shows `off` and requires confirmation.
|
|
54
|
+
|
|
55
|
+
Concurrent requests are FIFO at the root chooser.
|
|
56
|
+
A queued run does not open a dialog until it is admitted.
|
|
57
|
+
Cancel either dialog stops that run without creating a workspace or child session.
|
|
58
|
+
|
|
59
|
+
Missing UI, a UI that is not yet attached, an empty catalogue, or an invalid selection fails that run explicitly.
|
|
60
|
+
There is no default, timeout, remembered choice, or retry.
|
|
61
|
+
|
|
62
|
+
Nested children still route to the root UI, including when this package is excluded from them.
|
|
63
|
+
A child that also loads this package receives an `inherited` registration and installs no second queue.
|
|
64
|
+
|
|
65
|
+
`resume` does not re-ask.
|
|
66
|
+
The parent's active model, thinking level, agent files, and tool-call arguments are not rewritten to carry the choice.
|
|
67
|
+
|
|
68
|
+
## Lifecycle
|
|
69
|
+
|
|
70
|
+
The extension captures `getSubagentsService()` once during initialization and registers the provider immediately, before any `session_start` handler can spawn.
|
|
71
|
+
It attaches `ctx.ui.select` at `session_start` and closes the chooser at `session_shutdown`.
|
|
72
|
+
A request that arrives before the UI is attached fails closed rather than waiting for a later sequential handler.
|
|
73
|
+
|
|
74
|
+
Print, JSON, and other non-interactive sessions cannot satisfy the selection requirement.
|
|
75
|
+
|
|
76
|
+
## Limitations
|
|
77
|
+
|
|
78
|
+
- Out-of-process children and third-party session factories are not covered.
|
|
79
|
+
- The dialogs are Pi's generic `ui.select()`, not the native `/model` picker.
|
|
80
|
+
- Public task status stays `running` while waiting.
|
|
81
|
+
Pending selection is private widget, foreground, and background wording.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jopqior/pi-subagents-model-selector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that asks for model and thinking before every new pi-subagents run.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"imports": {
|
|
7
|
+
"#src/*": "./src/*",
|
|
8
|
+
"#test/*": "./test/*"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"pi-package",
|
|
15
|
+
"pi",
|
|
16
|
+
"pi-extension",
|
|
17
|
+
"pi-coding-agent",
|
|
18
|
+
"subagents",
|
|
19
|
+
"model-selector"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"author": {
|
|
25
|
+
"name": "Jopqior"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/Jopqior/gotgenes-pi-packages.git",
|
|
31
|
+
"directory": "packages/pi-subagents-model-selector"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/Jopqior/gotgenes-pi-packages/tree/main/packages/pi-subagents-model-selector#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/Jopqior/gotgenes-pi-packages/issues"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
},
|
|
40
|
+
"pi": {
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./src/index.ts"
|
|
43
|
+
]
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-ai": ">=0.84.4",
|
|
47
|
+
"@earendil-works/pi-coding-agent": ">=0.84.4"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@jopqior/pi-subagents": "^1.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@biomejs/biome": "^2.5.11",
|
|
54
|
+
"@earendil-works/pi-ai": "0.84.4",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
56
|
+
"@types/node": "^22.15.3",
|
|
57
|
+
"rumdl": "0.2.24",
|
|
58
|
+
"typescript": "^6.0.3",
|
|
59
|
+
"vitest": "^4.1.11"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"check": "pnpm --dir ../pi-subagents run build:types && tsc --noEmit",
|
|
63
|
+
"test": "vitest run",
|
|
64
|
+
"test:watch": "vitest",
|
|
65
|
+
"lint:md": "rumdl check .",
|
|
66
|
+
"lint": "biome check . && eslint . && pnpm run lint:md"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-subagents-model-selector — ask for model and thinking on every new run.
|
|
3
|
+
*
|
|
4
|
+
* Captures the core service once at initialization and registers the chooser
|
|
5
|
+
* before any session_start handler can spawn. An inherited registration
|
|
6
|
+
* installs nothing: the root chooser already serves the tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { getSubagentsService } from "@jopqior/pi-subagents";
|
|
11
|
+
import { ModelSelector } from "./model-selector";
|
|
12
|
+
|
|
13
|
+
const MISSING_CORE_MESSAGE =
|
|
14
|
+
"@jopqior/pi-subagents-model-selector requires @jopqior/pi-subagents with registerSpawnSelectionProvider, loaded before this extension.";
|
|
15
|
+
|
|
16
|
+
export default function piSubagentsModelSelectorExtension(
|
|
17
|
+
pi: ExtensionAPI,
|
|
18
|
+
): void {
|
|
19
|
+
const service = getSubagentsService();
|
|
20
|
+
if (service === undefined) {
|
|
21
|
+
throw new Error(MISSING_CORE_MESSAGE);
|
|
22
|
+
}
|
|
23
|
+
const register = (service as { registerSpawnSelectionProvider?: unknown })
|
|
24
|
+
.registerSpawnSelectionProvider;
|
|
25
|
+
if (typeof register !== "function") {
|
|
26
|
+
throw new Error(MISSING_CORE_MESSAGE);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const chooser = new ModelSelector();
|
|
30
|
+
const registration = service.registerSpawnSelectionProvider(chooser);
|
|
31
|
+
if (registration.kind === "inherited") {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
pi.on("session_start", (_event, ctx) => {
|
|
36
|
+
chooser.attachUI({
|
|
37
|
+
hasUI: ctx.hasUI,
|
|
38
|
+
select: (title, options, signal) =>
|
|
39
|
+
ctx.ui.select(title, options, { signal }),
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.on("session_shutdown", () => {
|
|
44
|
+
chooser.close();
|
|
45
|
+
registration.dispose();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-selector.ts — Two public Pi dialogs for one spawn selection.
|
|
3
|
+
*
|
|
4
|
+
* Model first, then thinking. Every option — including a single `off` — is
|
|
5
|
+
* confirmed explicitly. Missing UI is an error, not an approval.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
10
|
+
import type {
|
|
11
|
+
SpawnSelection,
|
|
12
|
+
SpawnSelectionProvider,
|
|
13
|
+
SpawnSelectionRequest,
|
|
14
|
+
} from "@jopqior/pi-subagents";
|
|
15
|
+
import {
|
|
16
|
+
SelectionQueue,
|
|
17
|
+
SelectionQueueCancelledError,
|
|
18
|
+
SelectionQueueClosedError,
|
|
19
|
+
} from "./selection-queue";
|
|
20
|
+
|
|
21
|
+
const DESCRIPTION_LIMIT = 80;
|
|
22
|
+
|
|
23
|
+
const NO_UI = "Spawn model selection requires an interactive UI.";
|
|
24
|
+
const NO_MODELS = "No models are available to select.";
|
|
25
|
+
const CLOSED = "Spawn model selection is closed.";
|
|
26
|
+
|
|
27
|
+
export interface SelectionUIPort {
|
|
28
|
+
readonly hasUI: boolean;
|
|
29
|
+
select(
|
|
30
|
+
title: string,
|
|
31
|
+
options: string[],
|
|
32
|
+
signal: AbortSignal,
|
|
33
|
+
): Promise<string | undefined>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ModelSelector implements SpawnSelectionProvider {
|
|
37
|
+
private readonly levelsFor: (
|
|
38
|
+
model: Model<Api>,
|
|
39
|
+
) => readonly SpawnSelection["thinkingLevel"][];
|
|
40
|
+
private readonly queue = new SelectionQueue();
|
|
41
|
+
private ui: SelectionUIPort | undefined;
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
deps: {
|
|
45
|
+
supportedThinkingLevels?: (
|
|
46
|
+
model: Model<Api>,
|
|
47
|
+
) => readonly SpawnSelection["thinkingLevel"][];
|
|
48
|
+
} = {},
|
|
49
|
+
) {
|
|
50
|
+
this.levelsFor = deps.supportedThinkingLevels ?? getSupportedThinkingLevels;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
attachUI(ui: SelectionUIPort): void {
|
|
54
|
+
this.ui = ui;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
close(): void {
|
|
58
|
+
this.ui = undefined;
|
|
59
|
+
this.queue.close();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async select(
|
|
63
|
+
request: SpawnSelectionRequest,
|
|
64
|
+
signal: AbortSignal,
|
|
65
|
+
): Promise<SpawnSelection | undefined> {
|
|
66
|
+
this.assertReady(request);
|
|
67
|
+
try {
|
|
68
|
+
return await this.queue.enqueue(
|
|
69
|
+
async (dialogSignal) => this.runDialogs(request, signal, dialogSignal),
|
|
70
|
+
signal,
|
|
71
|
+
);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (
|
|
74
|
+
err instanceof SelectionQueueCancelledError ||
|
|
75
|
+
err instanceof SelectionQueueClosedError
|
|
76
|
+
) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private assertReady(request: SpawnSelectionRequest): void {
|
|
84
|
+
if (this.queue.closed) {
|
|
85
|
+
throw new Error(CLOSED);
|
|
86
|
+
}
|
|
87
|
+
if (!this.ui?.hasUI) {
|
|
88
|
+
throw new Error(NO_UI);
|
|
89
|
+
}
|
|
90
|
+
if (request.availableModels.length === 0) {
|
|
91
|
+
throw new Error(NO_MODELS);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private async runDialogs(
|
|
96
|
+
request: SpawnSelectionRequest,
|
|
97
|
+
requestSignal: AbortSignal,
|
|
98
|
+
dialogSignal: AbortSignal,
|
|
99
|
+
): Promise<SpawnSelection | undefined> {
|
|
100
|
+
if (this.abandoned(requestSignal, dialogSignal)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const ui = this.ui;
|
|
104
|
+
if (!ui?.hasUI) {
|
|
105
|
+
throw new Error(NO_UI);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const labels = request.availableModels.map(modelLabel);
|
|
109
|
+
const modelChoice = await ui.select(
|
|
110
|
+
modelTitle(request),
|
|
111
|
+
[...labels],
|
|
112
|
+
dialogSignal,
|
|
113
|
+
);
|
|
114
|
+
if (
|
|
115
|
+
modelChoice === undefined ||
|
|
116
|
+
this.abandoned(requestSignal, dialogSignal)
|
|
117
|
+
) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const modelIndex = labels.indexOf(modelChoice);
|
|
121
|
+
const model =
|
|
122
|
+
modelIndex < 0 ? undefined : request.availableModels[modelIndex];
|
|
123
|
+
if (model === undefined) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Selected option ${JSON.stringify(modelChoice)} does not match any offered model.`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const levels = [...this.levelsFor(model)];
|
|
130
|
+
if (levels.length === 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Model ${model.provider}/${model.id} has no supported thinking levels.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const thinkingChoice = await ui.select(
|
|
136
|
+
thinkingTitle(model),
|
|
137
|
+
[...levels],
|
|
138
|
+
dialogSignal,
|
|
139
|
+
);
|
|
140
|
+
if (
|
|
141
|
+
thinkingChoice === undefined ||
|
|
142
|
+
this.abandoned(requestSignal, dialogSignal)
|
|
143
|
+
) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
if (!isListedLevel(thinkingChoice, levels)) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Selected thinking level ${JSON.stringify(thinkingChoice)} is not supported.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return { model, thinkingLevel: thinkingChoice };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private abandoned(
|
|
155
|
+
requestSignal: AbortSignal,
|
|
156
|
+
dialogSignal: AbortSignal,
|
|
157
|
+
): boolean {
|
|
158
|
+
return this.queue.closed || requestSignal.aborted || dialogSignal.aborted;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isListedLevel(
|
|
163
|
+
value: string,
|
|
164
|
+
levels: readonly SpawnSelection["thinkingLevel"][],
|
|
165
|
+
): value is SpawnSelection["thinkingLevel"] {
|
|
166
|
+
return levels.some((level) => level === value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function modelLabel(model: Model<Api>): string {
|
|
170
|
+
return `${model.provider}/${model.id} — ${model.name}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function boundDescription(description: string): string {
|
|
174
|
+
if (description.length <= DESCRIPTION_LIMIT) {
|
|
175
|
+
return description;
|
|
176
|
+
}
|
|
177
|
+
return `${description.slice(0, DESCRIPTION_LIMIT - 1)}…`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function modelTitle(request: SpawnSelectionRequest): string {
|
|
181
|
+
return `Select model for ${request.agentType} ${request.agentId} — ${boundDescription(request.description)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function thinkingTitle(model: Model<Api>): string {
|
|
185
|
+
return `Select thinking level for ${model.provider}/${model.id} (${model.name})`;
|
|
186
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selection-queue.ts — FIFO ownership for one root chooser's dialogs.
|
|
3
|
+
*
|
|
4
|
+
* Independent of Pi: jobs are thunks that receive a dialog AbortSignal.
|
|
5
|
+
* Cancelling a waiting request removes it without running it; cancelling the
|
|
6
|
+
* active request aborts its dialog signal. close() invalidates the active job
|
|
7
|
+
* and drains waiters without confirmation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class SelectionQueueCancelledError extends Error {
|
|
11
|
+
constructor(message = "The selection request was cancelled.") {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "SelectionQueueCancelledError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class SelectionQueueClosedError extends Error {
|
|
18
|
+
constructor(message = "The selection queue is closed.") {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "SelectionQueueClosedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Job<T> = (dialogSignal: AbortSignal) => Promise<T>;
|
|
25
|
+
|
|
26
|
+
interface Waiter {
|
|
27
|
+
readonly dialog: AbortController;
|
|
28
|
+
readonly reject: (reason: unknown) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class SelectionQueue {
|
|
32
|
+
#closed = false;
|
|
33
|
+
#tail: Promise<void> = Promise.resolve();
|
|
34
|
+
readonly #waiters = new Set<Waiter>();
|
|
35
|
+
|
|
36
|
+
get closed(): boolean {
|
|
37
|
+
return this.#closed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
enqueue<T>(job: Job<T>, signal: AbortSignal): Promise<T> {
|
|
41
|
+
if (this.#closed) {
|
|
42
|
+
return Promise.reject(new SelectionQueueClosedError());
|
|
43
|
+
}
|
|
44
|
+
if (signal.aborted) {
|
|
45
|
+
return Promise.reject(new SelectionQueueCancelledError());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
49
|
+
const dialog = new AbortController();
|
|
50
|
+
const waiter: Waiter = { dialog, reject };
|
|
51
|
+
this.#waiters.add(waiter);
|
|
52
|
+
|
|
53
|
+
const onAbort = (): void => {
|
|
54
|
+
this.#failWaiter(waiter, new SelectionQueueCancelledError());
|
|
55
|
+
};
|
|
56
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
57
|
+
|
|
58
|
+
const run = this.#tail.then(async () => {
|
|
59
|
+
if (!this.#waiters.has(waiter)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (this.#closed) {
|
|
63
|
+
this.#failWaiter(waiter, new SelectionQueueClosedError());
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (signal.aborted) {
|
|
67
|
+
this.#failWaiter(waiter, new SelectionQueueCancelledError());
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
resolve(await job(dialog.signal));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
reject(err);
|
|
74
|
+
} finally {
|
|
75
|
+
this.#waiters.delete(waiter);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
this.#tail = run.then(
|
|
80
|
+
() => undefined,
|
|
81
|
+
() => undefined,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const detachAbort = (): void => {
|
|
85
|
+
signal.removeEventListener("abort", onAbort);
|
|
86
|
+
};
|
|
87
|
+
void promise.then(detachAbort, detachAbort);
|
|
88
|
+
|
|
89
|
+
return promise;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close(): void {
|
|
93
|
+
if (this.#closed) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.#closed = true;
|
|
97
|
+
for (const waiter of [...this.#waiters]) {
|
|
98
|
+
this.#failWaiter(waiter, new SelectionQueueClosedError());
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
#failWaiter(waiter: Waiter, err: Error): void {
|
|
103
|
+
if (!this.#waiters.has(waiter)) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.#waiters.delete(waiter);
|
|
107
|
+
waiter.dialog.abort();
|
|
108
|
+
waiter.reject(err);
|
|
109
|
+
}
|
|
110
|
+
}
|