@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.
- package/CHANGELOG.md +30 -0
- package/dist/cli.js +2749 -2726
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/thinking.d.ts +8 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/package.json +12 -12
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/usage-cli.ts +5 -1
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +193 -49
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/parallel.ts +6 -1
- package/src/thinking.ts +9 -2
- package/src/tiny/title-client.ts +75 -21
- 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)
|
|
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
|
}
|
package/src/task/parallel.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/src/tiny/title-client.ts
CHANGED
|
@@ -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
|
-
):
|
|
131
|
+
): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
|
|
132
132
|
const { proc } = spawned;
|
|
133
|
-
return
|
|
134
|
-
|
|
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():
|
|
164
|
+
function spawnTinyTitleWorker(): RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound> {
|
|
139
165
|
return spawnWorkerOrUnavailable(
|
|
140
166
|
() => wrapSubprocess(createTinyTitleSubprocess()),
|
|
141
|
-
|
|
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:
|
|
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
|
-
#
|
|
180
|
+
#refed = false;
|
|
181
|
+
#spawnWorker: () => RefCountedWorkerHandle<TinyTitleWorkerInbound, TinyTitleWorkerOutbound>;
|
|
155
182
|
|
|
156
183
|
constructor(
|
|
157
|
-
spawnWorker: () =>
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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():
|
|
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.#
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|