@miphamai/cli 0.85.4 → 0.85.6
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/bin/mipham.ts +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +61 -6
- package/src/core/dream-engine.ts +17 -2
- package/src/core/engine.ts +95 -34
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/launch.ts +69 -2
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +86 -143
- package/src/i18n-core/locales/zh-CN.json +86 -143
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +159 -124
- package/src/providers/fetch-utils.ts +65 -34
- package/src/providers/openai-compat.ts +121 -106
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +290 -146
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +20 -6
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
|
@@ -78,6 +78,15 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
78
78
|
// off at the output ceiling rather than ended by the model.
|
|
79
79
|
let truncated = false
|
|
80
80
|
|
|
81
|
+
// Whether this stream reached `message_stop`. A stream that runs out without
|
|
82
|
+
// one was cut — a proxy or gateway closing the connection cleanly looks
|
|
83
|
+
// exactly like a finished response otherwise.
|
|
84
|
+
let sawTerminalEvent = false
|
|
85
|
+
|
|
86
|
+
// Tool blocks already emitted. A replayed event is the same call, not a
|
|
87
|
+
// second one; emitting it twice makes the engine run the tool twice.
|
|
88
|
+
const emittedToolIds = new Set<string>()
|
|
89
|
+
|
|
81
90
|
const messages = this.convertMessages(req.messages)
|
|
82
91
|
this.markPrefixCacheBreakpoint(messages)
|
|
83
92
|
|
|
@@ -123,6 +132,9 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
123
132
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
124
133
|
},
|
|
125
134
|
body: JSON.stringify(body),
|
|
135
|
+
// Same as `openai-compat`: without this the caller's signal never reaches
|
|
136
|
+
// the transport, and every per-call cancellation budget is decorative.
|
|
137
|
+
signal: req.signal,
|
|
126
138
|
})
|
|
127
139
|
|
|
128
140
|
if (!response.ok) {
|
|
@@ -144,150 +156,173 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
144
156
|
// passes aren't mistaken for a stalled connection.
|
|
145
157
|
const STREAM_READ_TIMEOUT_MS = streamIdleTimeoutMs(req.effort)
|
|
146
158
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
`Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
|
|
159
|
-
),
|
|
160
|
-
),
|
|
161
|
-
STREAM_READ_TIMEOUT_MS,
|
|
162
|
-
)
|
|
163
|
-
}),
|
|
164
|
-
])
|
|
165
|
-
} catch (err) {
|
|
166
|
-
yield { type: 'error', error: `Stream stalled: ${String(err)}` }
|
|
167
|
-
return
|
|
168
|
-
} finally {
|
|
169
|
-
if (idleTimer) clearTimeout(idleTimer)
|
|
170
|
-
}
|
|
171
|
-
const { done, value } = readResult
|
|
172
|
-
if (done) break
|
|
173
|
-
|
|
174
|
-
buffer += decoder.decode(value, { stream: true })
|
|
175
|
-
const lines = buffer.split('\n')
|
|
176
|
-
buffer = lines.pop() || ''
|
|
177
|
-
|
|
178
|
-
for (const line of lines) {
|
|
179
|
-
const trimmed = line.trim()
|
|
180
|
-
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
181
|
-
const data = trimmed.slice(6)
|
|
182
|
-
|
|
159
|
+
// The read loop and the trailing stop share one reader, and that reader owns
|
|
160
|
+
// the connection. `engine.ts` breaks out of this generator on the ordinary
|
|
161
|
+
// `stop` chunk, and a sub-agent throws mid-stream on abort — both call
|
|
162
|
+
// `.return()`, which unwinds through here. Without this, a turn that ends
|
|
163
|
+
// normally (or is abandoned) leaves the body unread and uncancelled, so the
|
|
164
|
+
// socket can't be reused. `cancel()` on an already-errored stream rejects, and
|
|
165
|
+
// on a closed one is a no-op — the catch covers the first.
|
|
166
|
+
try {
|
|
167
|
+
while (true) {
|
|
168
|
+
let readResult: Awaited<ReturnType<typeof reader.read>>
|
|
169
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined
|
|
183
170
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
171
|
+
readResult = await Promise.race([
|
|
172
|
+
reader.read(),
|
|
173
|
+
new Promise<never>((_, reject) => {
|
|
174
|
+
idleTimer = setTimeout(
|
|
175
|
+
() =>
|
|
176
|
+
reject(
|
|
177
|
+
new Error(
|
|
178
|
+
`Stream read timeout — no data for ${Math.round(STREAM_READ_TIMEOUT_MS / 1000)}s`,
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
STREAM_READ_TIMEOUT_MS,
|
|
182
|
+
)
|
|
183
|
+
}),
|
|
184
|
+
])
|
|
185
|
+
} catch (err) {
|
|
186
|
+
yield { type: 'error', error: `Stream stalled: ${String(err)}` }
|
|
187
|
+
return
|
|
188
|
+
} finally {
|
|
189
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
190
|
+
}
|
|
191
|
+
const { done, value } = readResult
|
|
192
|
+
if (done) break
|
|
193
|
+
|
|
194
|
+
buffer += decoder.decode(value, { stream: true })
|
|
195
|
+
const lines = buffer.split('\n')
|
|
196
|
+
buffer = lines.pop() || ''
|
|
197
|
+
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
const trimmed = line.trim()
|
|
200
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
201
|
+
const data = trimmed.slice(6)
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const event = JSON.parse(data) as AnthropicSSEEvent
|
|
205
|
+
|
|
206
|
+
switch (event.type) {
|
|
207
|
+
case 'content_block_start': {
|
|
208
|
+
const cb = event.content_block
|
|
209
|
+
if (!cb) continue
|
|
210
|
+
|
|
211
|
+
if (cb.type === 'tool_use') {
|
|
212
|
+
currentToolName = cb.name || ''
|
|
213
|
+
currentToolId = cb.id || ''
|
|
214
|
+
accumulatedToolInput = ''
|
|
215
|
+
}
|
|
216
|
+
break
|
|
205
217
|
}
|
|
206
218
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
219
|
+
case 'content_block_delta': {
|
|
220
|
+
const delta = event.delta
|
|
221
|
+
if (!delta) continue
|
|
210
222
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
break
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
case 'content_block_stop': {
|
|
218
|
-
// 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
|
|
219
|
-
// `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
|
|
220
|
-
// 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
|
|
221
|
-
// tool_call」的处置,这里结构上做不到(它的 finish_reason 与
|
|
222
|
-
// tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
|
|
223
|
-
// 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
|
|
224
|
-
// 那是一次行为变更,不属本次范围。
|
|
225
|
-
if (currentToolId && currentToolName && accumulatedToolInput) {
|
|
226
|
-
let parsedInput: Record<string, unknown> = {}
|
|
227
|
-
try {
|
|
228
|
-
parsedInput = JSON.parse(accumulatedToolInput)
|
|
229
|
-
} catch {
|
|
230
|
-
parsedInput = { _raw: accumulatedToolInput }
|
|
223
|
+
if (delta.type === 'text_delta' && delta.text) {
|
|
224
|
+
yield { type: 'text', content: delta.text }
|
|
231
225
|
}
|
|
232
226
|
|
|
233
|
-
|
|
234
|
-
type: '
|
|
235
|
-
toolUse: {
|
|
236
|
-
type: 'tool_use',
|
|
237
|
-
id: currentToolId,
|
|
238
|
-
name: currentToolName,
|
|
239
|
-
input: parsedInput,
|
|
240
|
-
},
|
|
227
|
+
if (delta.type === 'thinking_delta' && delta.text) {
|
|
228
|
+
yield { type: 'thinking', thinking: delta.text }
|
|
241
229
|
}
|
|
242
230
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
231
|
+
if (delta.type === 'input_json_delta' && delta.partial_json) {
|
|
232
|
+
accumulatedToolInput += delta.partial_json
|
|
233
|
+
}
|
|
234
|
+
break
|
|
247
235
|
}
|
|
248
|
-
break
|
|
249
|
-
}
|
|
250
236
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
237
|
+
case 'content_block_stop': {
|
|
238
|
+
// 此刻还无从得知本轮是否被截断 —— `stop_reason` 要到后面的
|
|
239
|
+
// `message_delta` 才到(见下方同名分支)。所以被截断的 `tool_use`
|
|
240
|
+
// 在这里已经发出去了;openai-compat 那条路上「截断即丢弃未完成的
|
|
241
|
+
// tool_call」的处置,这里结构上做不到(它的 finish_reason 与
|
|
242
|
+
// tool_calls 落在同一个响应体里)。**这是有意的不对称,不是漏做**:
|
|
243
|
+
// 要在这里丢弃,就得把 `tool_use` 缓冲到 `message_stop` 再发 ——
|
|
244
|
+
// 那是一次行为变更,不属本次范围。
|
|
245
|
+
if (currentToolId && currentToolName && accumulatedToolInput) {
|
|
246
|
+
// A replayed block carries the id it was first sent with, so the
|
|
247
|
+
// id is what tells a second call apart from the same call twice.
|
|
248
|
+
if (!emittedToolIds.has(currentToolId)) {
|
|
249
|
+
emittedToolIds.add(currentToolId)
|
|
250
|
+
|
|
251
|
+
let parsedInput: Record<string, unknown> = {}
|
|
252
|
+
try {
|
|
253
|
+
parsedInput = JSON.parse(accumulatedToolInput)
|
|
254
|
+
} catch {
|
|
255
|
+
parsedInput = { _raw: accumulatedToolInput }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
yield {
|
|
259
|
+
type: 'tool_use',
|
|
260
|
+
toolUse: {
|
|
261
|
+
type: 'tool_use',
|
|
262
|
+
id: currentToolId,
|
|
263
|
+
name: currentToolName,
|
|
264
|
+
input: parsedInput,
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Reset accumulator
|
|
270
|
+
currentToolName = ''
|
|
271
|
+
currentToolId = ''
|
|
272
|
+
accumulatedToolInput = ''
|
|
258
273
|
}
|
|
274
|
+
break
|
|
259
275
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
276
|
+
|
|
277
|
+
case 'message_delta': {
|
|
278
|
+
// Capture token usage for accurate cost tracking
|
|
279
|
+
if (event.usage) {
|
|
280
|
+
yield {
|
|
281
|
+
type: 'usage',
|
|
282
|
+
inputTokens: event.usage.input_tokens,
|
|
283
|
+
outputTokens: event.usage.output_tokens,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Contains stop_reason; also handles late input_json_delta
|
|
287
|
+
if (event.delta?.type === 'input_json_delta' && event.delta.partial_json) {
|
|
288
|
+
accumulatedToolInput += event.delta.partial_json
|
|
289
|
+
}
|
|
290
|
+
// `max_tokens` means the turn hit the output ceiling. Without this the
|
|
291
|
+
// truncation is indistinguishable from `end_turn`: both arrive here and
|
|
292
|
+
// the terminal stop below looks the same either way.
|
|
293
|
+
const stopReason = event.delta?.stop_reason
|
|
294
|
+
if (stopReason === 'max_tokens') {
|
|
295
|
+
truncated = true
|
|
296
|
+
}
|
|
297
|
+
break
|
|
270
298
|
}
|
|
271
|
-
break
|
|
272
|
-
}
|
|
273
299
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
300
|
+
case 'message_stop': {
|
|
301
|
+
sawTerminalEvent = true
|
|
302
|
+
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
303
|
+
return
|
|
304
|
+
}
|
|
278
305
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
306
|
+
case 'error': {
|
|
307
|
+
yield { type: 'error', error: event.error?.message || 'Unknown Anthropic error' }
|
|
308
|
+
return
|
|
309
|
+
}
|
|
282
310
|
}
|
|
311
|
+
} catch {
|
|
312
|
+
// Skip unparseable SSE events
|
|
283
313
|
}
|
|
284
|
-
} catch {
|
|
285
|
-
// Skip unparseable SSE events
|
|
286
314
|
}
|
|
287
315
|
}
|
|
288
|
-
}
|
|
289
316
|
|
|
290
|
-
|
|
317
|
+
// The stream ran out without `message_stop`. Whatever stopped it, the turn is
|
|
318
|
+
// incomplete — and this is the only place that knows, because a cleanly
|
|
319
|
+
// closed connection and a finished response are otherwise the same stream.
|
|
320
|
+
if (!sawTerminalEvent) truncated = true
|
|
321
|
+
|
|
322
|
+
yield truncated ? { type: 'stop', truncated: true } : { type: 'stop' }
|
|
323
|
+
} finally {
|
|
324
|
+
await reader.cancel().catch(() => {})
|
|
325
|
+
}
|
|
291
326
|
}
|
|
292
327
|
|
|
293
328
|
async listModels(): Promise<ModelInfo[]> {
|
|
@@ -16,6 +16,58 @@ export interface FetchWithRetryOptions {
|
|
|
16
16
|
|
|
17
17
|
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Upper bound on a server-supplied `Retry-After`, in ms.
|
|
21
|
+
*
|
|
22
|
+
* The header is a *request*, not a contract: a 5xx answering `Retry-After: 3600`
|
|
23
|
+
* used to park the CLI in `sleep` for a full hour with nothing on screen. The
|
|
24
|
+
* user cannot cancel what they cannot see, so the wait is capped and the reason
|
|
25
|
+
* is left in the comment rather than the terminal.
|
|
26
|
+
*/
|
|
27
|
+
export const RETRY_AFTER_MAX_MS = 60_000
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lower bound on a server-supplied `Retry-After`, in ms.
|
|
31
|
+
*
|
|
32
|
+
* `Retry-After: 0` is a real thing servers send, and honouring it literally
|
|
33
|
+
* means retrying the instant the previous attempt failed — a hammering loop
|
|
34
|
+
* dressed up as politeness. Any present-but-tiny value lands here instead.
|
|
35
|
+
*/
|
|
36
|
+
const RETRY_AFTER_MIN_MS = 1_000
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Delay before the next retry attempt.
|
|
40
|
+
*
|
|
41
|
+
* `Retry-After` is honoured when it is parseable, clamped to
|
|
42
|
+
* `[RETRY_AFTER_MIN_MS, RETRY_AFTER_MAX_MS]`, and **ignored in favour of
|
|
43
|
+
* exponential backoff when it is not** — an unparseable header must not become
|
|
44
|
+
* `sleep(NaN)`, which `setTimeout` reads as 0 (the same back-to-back retry as
|
|
45
|
+
* `Retry-After: 0`, but silent).
|
|
46
|
+
*
|
|
47
|
+
* Accepts both RFC 9110 forms: delta-seconds and an HTTP-date.
|
|
48
|
+
*/
|
|
49
|
+
export function retryDelayMs(
|
|
50
|
+
retryAfter: string | null,
|
|
51
|
+
attempt: number,
|
|
52
|
+
baseDelay: number,
|
|
53
|
+
): number {
|
|
54
|
+
const backoff = baseDelay * Math.pow(2, attempt)
|
|
55
|
+
if (retryAfter === null) return backoff
|
|
56
|
+
|
|
57
|
+
const header = retryAfter.trim()
|
|
58
|
+
const seconds = parseInt(header, 10)
|
|
59
|
+
let requested: number
|
|
60
|
+
if (!Number.isNaN(seconds)) {
|
|
61
|
+
requested = seconds * 1000
|
|
62
|
+
} else {
|
|
63
|
+
const at = Date.parse(header)
|
|
64
|
+
if (Number.isNaN(at)) return backoff // unparseable → backoff, never a zero sleep
|
|
65
|
+
requested = at - Date.now()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Math.min(Math.max(requested, RETRY_AFTER_MIN_MS), RETRY_AFTER_MAX_MS)
|
|
69
|
+
}
|
|
70
|
+
|
|
19
71
|
function isRetryableError(err: unknown): boolean {
|
|
20
72
|
if (err instanceof DOMException && err.name === 'AbortError') return false
|
|
21
73
|
return true
|
|
@@ -44,18 +96,20 @@ export async function fetchWithRetry(
|
|
|
44
96
|
timedOut = true
|
|
45
97
|
controller.abort()
|
|
46
98
|
}, timeout)
|
|
47
|
-
|
|
99
|
+
// `AbortSignal.any` (node ≥22 / bun ≥1.2, both in `engines`) instead of a
|
|
100
|
+
// hand-rolled combiner: it keeps a *weak* reference to the source signals, so
|
|
101
|
+
// the combination stays live for the reader without pinning the caller's
|
|
102
|
+
// signal — which is what the hand-rolled version had to trade away.
|
|
103
|
+
const signal = init.signal
|
|
104
|
+
? AbortSignal.any([init.signal, controller.signal])
|
|
105
|
+
: controller.signal
|
|
48
106
|
|
|
49
107
|
try {
|
|
50
108
|
const response = await fetch(url, { ...init, signal })
|
|
51
109
|
|
|
52
110
|
// 429 / 5xx → retry
|
|
53
111
|
if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
|
|
54
|
-
|
|
55
|
-
const delay = retryAfter
|
|
56
|
-
? parseInt(retryAfter, 10) * 1000
|
|
57
|
-
: baseDelay * Math.pow(2, attempt)
|
|
58
|
-
await sleep(delay)
|
|
112
|
+
await sleep(retryDelayMs(response.headers.get('Retry-After'), attempt, baseDelay))
|
|
59
113
|
continue
|
|
60
114
|
}
|
|
61
115
|
|
|
@@ -75,14 +129,11 @@ export async function fetchWithRetry(
|
|
|
75
129
|
await sleep(baseDelay * Math.pow(2, attempt))
|
|
76
130
|
} finally {
|
|
77
131
|
clearTimeout(timer)
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
/* best effort */
|
|
84
|
-
}
|
|
85
|
-
}
|
|
132
|
+
// Nothing to release: the combination is natively managed. Do NOT abort
|
|
133
|
+
// anything here — `fetch` holds that signal and the caller reads the body
|
|
134
|
+
// *after* we return, so aborting it at this point errored every response at
|
|
135
|
+
// the headers (measured on Node: the next read throws AbortError; Bun
|
|
136
|
+
// happens to tolerate it, which is why a Bun-only run never showed this).
|
|
86
137
|
}
|
|
87
138
|
}
|
|
88
139
|
|
|
@@ -115,23 +166,3 @@ export function streamIdleTimeoutMs(effort?: string): number {
|
|
|
115
166
|
const multiplier = effort ? (EFFORT_TIMEOUT_MULTIPLIER[effort] ?? 1) : 1
|
|
116
167
|
return STREAM_IDLE_TIMEOUT_BASE_MS * multiplier
|
|
117
168
|
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* Combine multiple AbortSignals into one — any signal aborting
|
|
121
|
-
* triggers the combined signal.
|
|
122
|
-
*/
|
|
123
|
-
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
124
|
-
const controller = new AbortController()
|
|
125
|
-
const onAbort = () => {
|
|
126
|
-
controller.abort()
|
|
127
|
-
for (const s of signals) s.removeEventListener('abort', onAbort)
|
|
128
|
-
}
|
|
129
|
-
for (const s of signals) {
|
|
130
|
-
if (s.aborted) {
|
|
131
|
-
controller.abort()
|
|
132
|
-
return controller.signal
|
|
133
|
-
}
|
|
134
|
-
s.addEventListener('abort', onAbort)
|
|
135
|
-
}
|
|
136
|
-
return controller.signal
|
|
137
|
-
}
|