@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.17

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli.js +2749 -2726
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/config/model-discovery.d.ts +1 -0
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  7. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  8. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  9. package/dist/types/hindsight/content.d.ts +2 -10
  10. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  15. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  16. package/dist/types/session/agent-session.d.ts +6 -6
  17. package/dist/types/session/provider-image-budget.d.ts +3 -0
  18. package/dist/types/session/session-context.d.ts +6 -5
  19. package/dist/types/task/parallel.d.ts +4 -0
  20. package/dist/types/thinking.d.ts +8 -1
  21. package/dist/types/tiny/title-client.d.ts +2 -2
  22. package/dist/types/utils/tools-manager.d.ts +2 -0
  23. package/package.json +12 -12
  24. package/src/auto-thinking/classifier.ts +7 -2
  25. package/src/cli/profile-alias.ts +38 -7
  26. package/src/cli/usage-cli.ts +5 -1
  27. package/src/config/model-discovery.ts +59 -8
  28. package/src/config/model-registry.ts +74 -3
  29. package/src/discovery/omp-extension-roots.ts +1 -3
  30. package/src/extensibility/extensions/wrapper.ts +3 -2
  31. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  33. package/src/extensibility/plugins/loader.ts +71 -23
  34. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  35. package/src/extensibility/tool-event-input.ts +57 -0
  36. package/src/hindsight/content.ts +21 -12
  37. package/src/mcp/tool-bridge.ts +27 -2
  38. package/src/mnemopi/state.ts +5 -2
  39. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  40. package/src/modes/components/chat-transcript-builder.ts +6 -0
  41. package/src/modes/components/custom-editor.test.ts +18 -1
  42. package/src/modes/components/custom-editor.ts +77 -45
  43. package/src/modes/components/hook-editor.ts +15 -2
  44. package/src/modes/components/settings-selector.ts +2 -2
  45. package/src/modes/components/status-line/component.ts +52 -8
  46. package/src/modes/components/status-line/segments.ts +5 -1
  47. package/src/modes/components/status-line/types.ts +1 -0
  48. package/src/modes/components/welcome.ts +12 -14
  49. package/src/modes/controllers/command-controller.ts +16 -5
  50. package/src/modes/controllers/input-controller.ts +115 -3
  51. package/src/modes/controllers/selector-controller.ts +19 -1
  52. package/src/modes/interactive-mode.ts +3 -3
  53. package/src/modes/utils/ui-helpers.ts +3 -3
  54. package/src/sdk.ts +8 -10
  55. package/src/session/agent-session.ts +193 -49
  56. package/src/session/provider-image-budget.ts +86 -0
  57. package/src/session/session-context.ts +14 -7
  58. package/src/session/session-storage.ts +24 -2
  59. package/src/session/snapcompact-inline.ts +19 -3
  60. package/src/slash-commands/builtin-registry.ts +0 -22
  61. package/src/slash-commands/helpers/usage-report.ts +9 -1
  62. package/src/task/parallel.ts +6 -1
  63. package/src/thinking.ts +9 -2
  64. package/src/tiny/title-client.ts +75 -21
  65. package/src/utils/tools-manager.ts +67 -10
@@ -239,11 +239,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
239
239
  return "Plan: off";
240
240
  },
241
241
  handleTui: async (command, runtime) => {
242
- const hadArgs = !!command.args;
243
242
  await runtime.ctx.handlePlanModeCommand(command.args || undefined);
244
- if (hadArgs) {
245
- runtime.ctx.editor.addToHistory(command.text);
246
- }
247
243
  runtime.ctx.editor.setText("");
248
244
  },
249
245
  },
@@ -277,11 +273,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
277
273
  return state ? `Goal: ${state.goal.status} (${shortDetail(state.goal.objective)})` : "Goal: off";
278
274
  },
279
275
  handleTui: async (command, runtime) => {
280
- const hadArgs = !!command.args;
281
276
  await runtime.ctx.handleGoalModeCommand(command.args || undefined);
282
- if (hadArgs) {
283
- runtime.ctx.editor.addToHistory(command.text);
284
- }
285
277
  runtime.ctx.editor.setText("");
286
278
  },
287
279
  },
@@ -1270,7 +1262,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1270
1262
  allowArgs: true,
1271
1263
  handle: handleMcpAcp,
1272
1264
  handleTui: async (command, runtime) => {
1273
- runtime.ctx.editor.addToHistory(command.text);
1274
1265
  runtime.ctx.editor.setText("");
1275
1266
  await runtime.ctx.handleMCPCommand(command.text);
1276
1267
  },
@@ -1293,7 +1284,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1293
1284
  allowArgs: true,
1294
1285
  handle: handleSshAcp,
1295
1286
  handleTui: async (command, runtime) => {
1296
- runtime.ctx.editor.addToHistory(command.text);
1297
1287
  runtime.ctx.editor.setText("");
1298
1288
  await runtime.ctx.handleSSHCommand(command.text);
1299
1289
  },
@@ -1436,9 +1426,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1436
1426
  allowArgs: true,
1437
1427
  handleTui: async (command, runtime) => {
1438
1428
  const question = command.text.slice(`/${command.name}`.length).trim();
1439
- if (question) {
1440
- runtime.ctx.editor.addToHistory(command.text);
1441
- }
1442
1429
  runtime.ctx.editor.setText("");
1443
1430
  await runtime.ctx.handleBtwCommand(question);
1444
1431
  },
@@ -1450,9 +1437,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1450
1437
  allowArgs: true,
1451
1438
  handleTui: async (command, runtime) => {
1452
1439
  const work = command.text.slice(`/${command.name}`.length).trim();
1453
- if (work) {
1454
- runtime.ctx.editor.addToHistory(command.text);
1455
- }
1456
1440
  runtime.ctx.editor.setText("");
1457
1441
  await runtime.ctx.handleTanCommand(work);
1458
1442
  },
@@ -1464,9 +1448,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1464
1448
  allowArgs: true,
1465
1449
  handleTui: async (command, runtime) => {
1466
1450
  const complaint = command.text.slice(`/${command.name}`.length).trim();
1467
- if (complaint) {
1468
- runtime.ctx.editor.addToHistory(command.text);
1469
- }
1470
1451
  runtime.ctx.editor.setText("");
1471
1452
  await runtime.ctx.handleOmfgCommand(complaint);
1472
1453
  },
@@ -1558,7 +1539,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1558
1539
  }
1559
1540
  },
1560
1541
  handleTui: async (command, runtime) => {
1561
- runtime.ctx.editor.addToHistory(command.text);
1562
1542
  runtime.ctx.editor.setText("");
1563
1543
  await runtime.ctx.handleMemoryCommand(command.text);
1564
1544
  },
@@ -1586,7 +1566,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1586
1566
  runtime.ctx.editor.setText("");
1587
1567
  return;
1588
1568
  }
1589
- runtime.ctx.editor.addToHistory(command.text);
1590
1569
  runtime.ctx.editor.setText("");
1591
1570
  await runtime.ctx.handleRenameCommand(title);
1592
1571
  },
@@ -1629,7 +1608,6 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1629
1608
  runtime.ctx.editor.setText("");
1630
1609
  return;
1631
1610
  }
1632
- runtime.ctx.editor.addToHistory(command.text);
1633
1611
  runtime.ctx.editor.setText("");
1634
1612
  await runtime.ctx.handleMoveCommand(targetPath);
1635
1613
  },
@@ -1,4 +1,5 @@
1
1
  import type { UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
2
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
2
3
  import type { OAuthAccountIdentity } from "../../session/auth-storage";
3
4
  import type { SlashCommandRuntime } from "../types";
4
5
  import { reportMatchesActiveAccount } from "./active-oauth-account";
@@ -52,6 +53,10 @@ function renderUsageReports(
52
53
  )) {
53
54
  lines.push("", formatProviderName(provider));
54
55
  const activeAccount = resolveActiveAccount?.(provider);
56
+ // Provider-wide disclaimers render once per provider, not per limit.
57
+ const providerNotes = [...new Set(providerReports.flatMap(report => report.notes ?? []))];
58
+ for (const note of providerNotes)
59
+ lines.push(` ${sanitizeText(note.replace(/[\r\n]+/g, " ").replace(/\t/g, " "))}`);
55
60
  for (const report of providerReports) {
56
61
  const inUse = reportMatchesActiveAccount(report, activeAccount);
57
62
  const savedResets = report.resetCredits?.availableCount ?? 0;
@@ -83,7 +88,10 @@ function renderUsageReports(
83
88
  if (limit.window?.resetsAt && limit.window.resetsAt > nowMs) {
84
89
  lines.push(` resets in ${formatDuration(limit.window.resetsAt - nowMs)}`);
85
90
  }
86
- if (limit.notes && limit.notes.length > 0) lines.push(` ${limit.notes.join(" • ")}`);
91
+ if (limit.notes && limit.notes.length > 0)
92
+ lines.push(
93
+ ` ${limit.notes.map(n => sanitizeText(n.replace(/[\r\n]+/g, " ").replace(/\t/g, " "))).join(" • ")}`,
94
+ );
87
95
  }
88
96
  }
89
97
  }
@@ -85,6 +85,10 @@ export async function mapWithConcurrencyLimit<T, R>(
85
85
 
86
86
  /**
87
87
  * Simple counting semaphore for limiting concurrency across independently-scheduled async work.
88
+ *
89
+ * `max <= 0` (or any non-finite input) means unbounded — every `acquire()` resolves
90
+ * immediately — matching `task.maxConcurrency = 0`'s "Unlimited" semantics in the
91
+ * settings UI ([#3305](https://github.com/can1357/oh-my-pi/issues/3305)).
88
92
  */
89
93
  export class Semaphore {
90
94
  #max: number;
@@ -92,7 +96,8 @@ export class Semaphore {
92
96
  #queue: Array<() => void> = [];
93
97
 
94
98
  constructor(max: number) {
95
- this.#max = Math.max(1, max);
99
+ const normalizedMax = Number.isFinite(max) ? Math.trunc(max) : 0;
100
+ this.#max = normalizedMax > 0 ? normalizedMax : Number.POSITIVE_INFINITY;
96
101
  }
97
102
 
98
103
  async acquire(): Promise<void> {
package/src/thinking.ts CHANGED
@@ -180,10 +180,17 @@ export function parseCliThinkingLevel(value: string | null | undefined): Configu
180
180
  * above Low (falling back to the full supported set only when the model maxes
181
181
  * out below Low). Within that pool the request snaps to the highest level not
182
182
  * exceeding it, or the pool minimum when the request is below the pool.
183
+ *
184
+ * Returns `undefined` for reasoning-capable models without a controllable
185
+ * effort surface (`thinking.efforts` empty — e.g. devin-agent models, where
186
+ * Cascade selects effort by routing to sibling model ids). Matches
187
+ * {@link clampThinkingLevelForModel}: with no effort to pick, `auto` must not
188
+ * forward a concrete effort that would then trip {@link requireSupportedEffort}
189
+ * downstream.
183
190
  */
184
- export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort {
191
+ export function clampAutoThinkingEffort(model: Model | undefined, effort: Effort): Effort | undefined {
185
192
  const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
186
- if (supported.length === 0) return effort;
193
+ if (supported.length === 0) return undefined;
187
194
  const lowIndex = THINKING_EFFORTS.indexOf(Effort.Low);
188
195
  const eligible = supported.filter(level => THINKING_EFFORTS.indexOf(level) >= lowIndex);
189
196
  const pool = eligible.length > 0 ? eligible : supported;
@@ -5,12 +5,12 @@ import {
5
5
  createWorkerHandle,
6
6
  createWorkerSubprocess,
7
7
  logWorkerMessage,
8
+ type RefCountedWorkerHandle,
8
9
  resolveWorkerSpawnCmd,
9
10
  SMOKE_TEST_TIMEOUT_MS,
10
11
  type SpawnedSubprocess,
11
12
  smokeTestWorker,
12
13
  spawnWorkerOrUnavailable,
13
- type WorkerHandle,
14
14
  workerEnvFromParent,
15
15
  } from "../subprocess/worker-client";
16
16
  import { safeSend } from "../utils/ipc";
@@ -128,33 +128,60 @@ export function createTinyTitleSubprocess(): SpawnedSubprocess<TinyTitleWorkerOu
128
128
 
129
129
  function wrapSubprocess(
130
130
  spawned: SpawnedSubprocess<TinyTitleWorkerOutbound>,
131
- ): WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
131
+ ): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
132
132
  const { proc } = spawned;
133
- return createWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>(spawned, message =>
134
- safeSend(proc, message, "tiny-title"),
135
- );
133
+ return {
134
+ ...createWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>(spawned, message =>
135
+ safeSend(proc, message, "tiny-title"),
136
+ ),
137
+ ref() {
138
+ try {
139
+ proc.ref();
140
+ } catch {
141
+ // Already gone.
142
+ }
143
+ },
144
+ unref() {
145
+ try {
146
+ proc.unref();
147
+ } catch {
148
+ // Already gone.
149
+ }
150
+ },
151
+ };
152
+ }
153
+
154
+ function spawnInlineUnavailableWorker(
155
+ error: unknown,
156
+ ): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
157
+ return {
158
+ ...createUnavailableWorker<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>(error),
159
+ ref() {},
160
+ unref() {},
161
+ };
136
162
  }
137
163
 
138
- function spawnTinyTitleWorker(): WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
164
+ function spawnTinyTitleWorker(): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
139
165
  return spawnWorkerOrUnavailable(
140
166
  () => wrapSubprocess(createTinyTitleSubprocess()),
141
- createUnavailableWorker<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>,
167
+ spawnInlineUnavailableWorker,
142
168
  "Tiny title worker spawn failed; local titles disabled",
143
169
  );
144
170
  }
145
171
 
146
172
  export class TinyTitleClient {
147
- #worker: WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> | null = null;
173
+ #worker: RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> | null = null;
148
174
  #unsubscribeMessage: (() => void) | null = null;
149
175
  #unsubscribeError: (() => void) | null = null;
150
176
  #pending = new Map<string, PendingRequest>();
151
177
  #failedModels = new Set<TinyLocalModelKey>();
152
178
  #progressListeners = new Set<(event: TinyTitleProgressEvent) => void>();
153
179
  #nextRequestId = 0;
154
- #spawnWorker: () => WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>;
180
+ #refed = false;
181
+ #spawnWorker: () => RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>;
155
182
 
156
183
  constructor(
157
- spawnWorker: () => WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> = spawnTinyTitleWorker,
184
+ spawnWorker: () => RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> = spawnTinyTitleWorker,
158
185
  ) {
159
186
  this.#spawnWorker = spawnWorker;
160
187
  }
@@ -179,11 +206,11 @@ export class TinyTitleClient {
179
206
  const worker = this.#ensureWorker();
180
207
  const id = String(++this.#nextRequestId);
181
208
  const { promise, resolve } = Promise.withResolvers<string | null>();
182
- this.#pending.set(id, { kind: "generate", modelKey, resolve });
209
+ this.#addPending(id, { kind: "generate", modelKey, resolve });
183
210
  const abort = (): void => {
184
211
  const pending = this.#pending.get(id);
185
212
  if (pending?.kind !== "generate") return;
186
- this.#pending.delete(id);
213
+ this.#deletePending(id);
187
214
  pending.resolve(null);
188
215
  };
189
216
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -195,7 +222,7 @@ export class TinyTitleClient {
195
222
  return await promise;
196
223
  } finally {
197
224
  options.signal?.removeEventListener("abort", abort);
198
- this.#pending.delete(id);
225
+ this.#deletePending(id);
199
226
  }
200
227
  } catch (error) {
201
228
  logger.debug("tiny-title: local generation failed", {
@@ -218,11 +245,11 @@ export class TinyTitleClient {
218
245
  const worker = this.#ensureWorker();
219
246
  const id = String(++this.#nextRequestId);
220
247
  const { promise, resolve } = Promise.withResolvers<string | null>();
221
- this.#pending.set(id, { kind: "complete", modelKey, resolve });
248
+ this.#addPending(id, { kind: "complete", modelKey, resolve });
222
249
  const abort = (): void => {
223
250
  const pending = this.#pending.get(id);
224
251
  if (pending?.kind !== "complete") return;
225
- this.#pending.delete(id);
252
+ this.#deletePending(id);
226
253
  pending.resolve(null);
227
254
  };
228
255
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -231,7 +258,7 @@ export class TinyTitleClient {
231
258
  return await promise;
232
259
  } finally {
233
260
  options.signal?.removeEventListener("abort", abort);
234
- this.#pending.delete(id);
261
+ this.#deletePending(id);
235
262
  }
236
263
  } catch (error) {
237
264
  logger.debug("tiny-model: local completion failed", {
@@ -251,11 +278,11 @@ export class TinyTitleClient {
251
278
  const worker = this.#ensureWorker();
252
279
  const id = String(++this.#nextRequestId);
253
280
  const { promise, resolve } = Promise.withResolvers<boolean>();
254
- this.#pending.set(id, { kind: "download", modelKey, resolve });
281
+ this.#addPending(id, { kind: "download", modelKey, resolve });
255
282
  const abort = (): void => {
256
283
  const pending = this.#pending.get(id);
257
284
  if (pending?.kind !== "download") return;
258
- this.#pending.delete(id);
285
+ this.#deletePending(id);
259
286
  pending.resolve(false);
260
287
  };
261
288
  options.signal?.addEventListener("abort", abort, { once: true });
@@ -264,7 +291,7 @@ export class TinyTitleClient {
264
291
  return await promise;
265
292
  } finally {
266
293
  options.signal?.removeEventListener("abort", abort);
267
- this.#pending.delete(id);
294
+ this.#deletePending(id);
268
295
  }
269
296
  } catch (error) {
270
297
  logger.debug("tiny-title: local model download failed", {
@@ -290,6 +317,7 @@ export class TinyTitleClient {
290
317
  else pending.resolve(false);
291
318
  }
292
319
  this.#pending.clear();
320
+ this.#refed = false;
293
321
  try {
294
322
  await worker?.terminate();
295
323
  } catch {
@@ -297,7 +325,7 @@ export class TinyTitleClient {
297
325
  }
298
326
  }
299
327
 
300
- #ensureWorker(): WorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
328
+ #ensureWorker(): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
301
329
  if (this.#worker) return this.#worker;
302
330
  const worker = this.#spawnWorker();
303
331
  this.#worker = worker;
@@ -306,6 +334,32 @@ export class TinyTitleClient {
306
334
  return worker;
307
335
  }
308
336
 
337
+ /** Register a pending request and keep the worker referenced while work is in flight. */
338
+ #addPending(id: string, request: PendingRequest): void {
339
+ this.#pending.set(id, request);
340
+ this.#syncWorkerRef();
341
+ }
342
+
343
+ /** Drop a pending request and unref the worker once nothing is in flight. */
344
+ #deletePending(id: string): void {
345
+ if (this.#pending.delete(id)) this.#syncWorkerRef();
346
+ }
347
+
348
+ /**
349
+ * Tiny-model workers are spawned `unref`'d so idle TUI sessions can exit.
350
+ * Short-lived CLI downloads need the opposite while awaiting worker IPC, or
351
+ * Bun can drain the event loop before the subprocess answers.
352
+ */
353
+ #syncWorkerRef(): void {
354
+ const worker = this.#worker;
355
+ if (!worker) return;
356
+ const shouldRef = this.#pending.size > 0;
357
+ if (shouldRef === this.#refed) return;
358
+ this.#refed = shouldRef;
359
+ if (shouldRef) worker.ref();
360
+ else worker.unref();
361
+ }
362
+
309
363
  #handleMessage(message: TinyTitleWorkerOutbound): void {
310
364
  if (message.type === "log") {
311
365
  logWorkerMessage(message);
@@ -319,7 +373,7 @@ export class TinyTitleClient {
319
373
 
320
374
  const pending = this.#pending.get(message.id);
321
375
  if (!pending) return;
322
- this.#pending.delete(message.id);
376
+ this.#deletePending(message.id);
323
377
  if (message.type === "title") {
324
378
  if (pending.kind === "generate") pending.resolve(message.title);
325
379
  return;
@@ -8,6 +8,62 @@ const TOOLS_DIR = getToolsDir();
8
8
  const TOOL_DOWNLOAD_TIMEOUT_MS = 120_000;
9
9
  const TOOL_METADATA_TIMEOUT_MS = 5000;
10
10
 
11
+ type BodyReadResult = Bun.ReadableStreamDefaultReadResult<Uint8Array>;
12
+ type BodyReader = {
13
+ read(): Promise<BodyReadResult>;
14
+ cancel(reason?: unknown): Promise<void>;
15
+ };
16
+
17
+ function isAbortLikeError(error: unknown): boolean {
18
+ return error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
19
+ }
20
+
21
+ function abortReason(signal: AbortSignal): unknown {
22
+ return signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
23
+ }
24
+
25
+ async function readBodyChunk(reader: BodyReader, signal: AbortSignal | undefined): Promise<BodyReadResult> {
26
+ if (!signal) return await reader.read();
27
+ if (signal.aborted) throw abortReason(signal);
28
+
29
+ const abort = Promise.withResolvers<BodyReadResult>();
30
+ const onAbort = () => abort.reject(abortReason(signal));
31
+ signal.addEventListener("abort", onAbort, { once: true });
32
+ try {
33
+ return await Promise.race([reader.read(), abort.promise]);
34
+ } finally {
35
+ signal.removeEventListener("abort", onAbort);
36
+ }
37
+ }
38
+
39
+ async function writeResponseBody(
40
+ dest: string,
41
+ body: NonNullable<Response["body"]>,
42
+ signal?: AbortSignal,
43
+ ): Promise<void> {
44
+ const reader = body.getReader();
45
+ const sink = Bun.file(dest).writer();
46
+ let completed = false;
47
+
48
+ try {
49
+ while (true) {
50
+ const { done, value } = await readBodyChunk(reader, signal);
51
+ if (done) break;
52
+ if (value) {
53
+ await sink.write(value);
54
+ }
55
+ }
56
+ await sink.end();
57
+ completed = true;
58
+ } finally {
59
+ if (!completed) {
60
+ await reader.cancel().catch(() => {});
61
+ await Promise.resolve(sink.end()).catch(() => {});
62
+ await fs.promises.rm(dest, { force: true }).catch(() => {});
63
+ }
64
+ }
65
+ }
66
+
11
67
  interface ToolConfig {
12
68
  name: string;
13
69
  repo: string; // GitHub repo (e.g., "sharkdp/fd")
@@ -154,25 +210,26 @@ async function getLatestVersion(repo: string, signal?: AbortSignal): Promise<str
154
210
  return data.tag_name.replace(/^v/, "");
155
211
  }
156
212
 
157
- // Download a file from URL
158
- async function downloadFile(url: string, dest: string, signal?: AbortSignal): Promise<void> {
213
+ /** Download a tool asset without handing the streaming Response to Bun.write. */
214
+ export async function downloadFile(url: string, dest: string, signal?: AbortSignal): Promise<void> {
215
+ const downloadSignal = ptree.combineSignals(signal, TOOL_DOWNLOAD_TIMEOUT_MS);
159
216
  let response: Response;
160
217
  try {
161
218
  response = await fetch(url, {
162
- signal: ptree.combineSignals(signal, TOOL_DOWNLOAD_TIMEOUT_MS),
219
+ signal: downloadSignal,
163
220
  });
221
+ if (!response.ok) {
222
+ throw new Error(`Failed to download: ${response.status}`);
223
+ } else if (!response.body) {
224
+ throw new Error("No response body");
225
+ }
226
+ await writeResponseBody(dest, response.body, downloadSignal);
164
227
  } catch (err) {
165
- if (err instanceof Error && err.name === "AbortError") {
228
+ if (isAbortLikeError(err)) {
166
229
  throw new Error(`Download timed out: ${url}`);
167
230
  }
168
231
  throw err;
169
232
  }
170
- if (!response.ok) {
171
- throw new Error(`Failed to download: ${response.status}`);
172
- } else if (!response.body) {
173
- throw new Error("No response body");
174
- }
175
- await Bun.write(dest, response);
176
233
  }
177
234
 
178
235
  // Download and install a tool