@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.11

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.
@@ -35,6 +35,31 @@ import type { ProviderDiscovery } from "./models-config-schema";
35
35
  export const DISCOVERY_DEFAULT_CONTEXT_WINDOW = OPENAI_COMPAT_DISCOVERY_DEFAULT_CONTEXT_WINDOW;
36
36
  export const DISCOVERY_DEFAULT_MAX_TOKENS = OPENAI_COMPAT_DISCOVERY_DEFAULT_MAX_TOKENS;
37
37
 
38
+ /**
39
+ * Run `fn` with an abort signal that fires after `timeoutMs`, clearing the
40
+ * backing timer the instant the operation settles.
41
+ *
42
+ * Unlike the built-in abort-signal timeout API, the timer never outlives the
43
+ * request: on the success path it is cancelled before `fn` resolves, so the
44
+ * signal is never aborted and no pending callback lingers on the heap. A leaked
45
+ * abort-signal timeout (e.g. discovery against a mocked fetch that resolves
46
+ * instantly) fires seconds later and sets its abort `reason` — which crashed
47
+ * Bun's concurrent GC while it marked the signal's wrapped reason during an
48
+ * unrelated allocation (`JSAbortSignal::visitAdditionalChildren`).
49
+ */
50
+ async function withTimeoutSignal<T>(timeoutMs: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T> {
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(
53
+ () => controller.abort(new DOMException("The operation timed out.", "TimeoutError")),
54
+ timeoutMs,
55
+ );
56
+ try {
57
+ return await fn(controller.signal);
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
38
63
  const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434";
39
64
  const OLLAMA_HOST_DEFAULT_PORT = "11434";
40
65
 
@@ -288,16 +313,18 @@ async function discoverOllamaModelMetadata(
288
313
  ): Promise<OllamaDiscoveredModelMetadata | null> {
289
314
  const showUrl = `${endpoint}/api/show`;
290
315
  try {
291
- const response = await ctx.fetch(showUrl, {
292
- method: "POST",
293
- headers: { ...(headers ?? {}), "Content-Type": "application/json" },
294
- body: JSON.stringify({ model: modelId }),
295
- signal: AbortSignal.timeout(150),
316
+ const payload = await withTimeoutSignal(150, async signal => {
317
+ const response = await ctx.fetch(showUrl, {
318
+ method: "POST",
319
+ headers: { ...(headers ?? {}), "Content-Type": "application/json" },
320
+ body: JSON.stringify({ model: modelId }),
321
+ signal,
322
+ });
323
+ if (!response.ok) {
324
+ return null;
325
+ }
326
+ return (await response.json()) as unknown;
296
327
  });
297
- if (!response.ok) {
298
- return null;
299
- }
300
- const payload = (await response.json()) as unknown;
301
328
  if (!isRecord(payload)) {
302
329
  return null;
303
330
  }
@@ -339,14 +366,16 @@ export async function discoverOllamaModels(
339
366
  const endpoint = normalizeOllamaBaseUrl(providerConfig.baseUrl);
340
367
  const tagsUrl = `${endpoint}/api/tags`;
341
368
  const headers = { ...(providerConfig.headers ?? {}) };
342
- const response = await ctx.fetch(tagsUrl, {
343
- headers,
344
- signal: AbortSignal.timeout(250),
369
+ const payload = await withTimeoutSignal(250, async signal => {
370
+ const response = await ctx.fetch(tagsUrl, {
371
+ headers,
372
+ signal,
373
+ });
374
+ if (!response.ok) {
375
+ throw new Error(`HTTP ${response.status} from ${tagsUrl}`);
376
+ }
377
+ return (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
345
378
  });
346
- if (!response.ok) {
347
- throw new Error(`HTTP ${response.status} from ${tagsUrl}`);
348
- }
349
- const payload = (await response.json()) as { models?: Array<{ name?: string; model?: string }> };
350
379
  const entries = (payload.models ?? []).flatMap(item => {
351
380
  const id = item.model || item.name;
352
381
  return id ? [{ id, name: item.name || id }] : [];
@@ -384,14 +413,16 @@ async function discoverLlamaCppServerMetadata(
384
413
  ): Promise<LlamaCppDiscoveredServerMetadata | null> {
385
414
  const propsUrl = `${toLlamaCppNativeBaseUrl(baseUrl)}/props`;
386
415
  try {
387
- const response = await ctx.fetch(propsUrl, {
388
- headers,
389
- signal: AbortSignal.timeout(150),
416
+ const payload = await withTimeoutSignal(150, async signal => {
417
+ const response = await ctx.fetch(propsUrl, {
418
+ headers,
419
+ signal,
420
+ });
421
+ if (!response.ok) {
422
+ return null;
423
+ }
424
+ return (await response.json()) as unknown;
390
425
  });
391
- if (!response.ok) {
392
- return null;
393
- }
394
- const payload = (await response.json()) as unknown;
395
426
  if (!isRecord(payload)) {
396
427
  return null;
397
428
  }
@@ -415,24 +446,26 @@ export async function discoverLlamaCppModels(
415
446
  const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
416
447
  let headers = baseHeaders;
417
448
  const attempt = async (h: Record<string, string>) => {
418
- const [response, metadata] = await Promise.all([
419
- ctx.fetch(modelsUrl, {
420
- headers: h,
421
- signal: AbortSignal.timeout(250),
449
+ const [payload, metadata] = await Promise.all([
450
+ withTimeoutSignal(250, async signal => {
451
+ const response = await ctx.fetch(modelsUrl, {
452
+ headers: h,
453
+ signal,
454
+ });
455
+ if (!response.ok) {
456
+ throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
457
+ }
458
+ headers = h;
459
+ return (await response.json()) as unknown;
422
460
  }),
423
461
  discoverLlamaCppServerMetadata(ctx, baseUrl, h),
424
462
  ]);
425
- if (!response.ok) {
426
- throw new Error(`HTTP ${response.status} from ${modelsUrl}`);
427
- }
428
- headers = h;
429
- return [response, metadata] as const;
463
+ return [payload, metadata] as const;
430
464
  };
431
465
  const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
432
- const [response, serverMetadata] = apiKey
466
+ const [payload, serverMetadata] = apiKey
433
467
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
434
468
  : await attempt(baseHeaders);
435
- const payload = (await response.json()) as unknown;
436
469
  const models = parseLlamaCppModelList(payload);
437
470
  const discovered: Model<Api>[] = [];
438
471
  for (const item of models) {
@@ -476,17 +509,22 @@ export async function discoverLlamaCppModelRuntimeMetadata(
476
509
  const modelsUrl = `${baseUrl}/models`;
477
510
  const baseHeaders: Record<string, string> = { ...(model.headers ?? {}) };
478
511
  const attempt = async (headers: Record<string, string>) => {
479
- const [response, serverMetadata] = await Promise.all([
480
- ctx.fetch(modelsUrl, {
481
- headers,
482
- signal: AbortSignal.timeout(250),
512
+ const [entries, serverMetadata] = await Promise.all([
513
+ withTimeoutSignal(250, async signal => {
514
+ const response = await ctx.fetch(modelsUrl, {
515
+ headers,
516
+ signal,
517
+ });
518
+ if (!response.ok) {
519
+ return undefined;
520
+ }
521
+ return parseLlamaCppModelList(await response.json());
483
522
  }),
484
523
  discoverLlamaCppServerMetadata(ctx, baseUrl, headers),
485
524
  ]);
486
- if (!response.ok) {
525
+ if (!entries) {
487
526
  return undefined;
488
527
  }
489
- const entries = parseLlamaCppModelList(await response.json());
490
528
  const entry = entries.find(entry => entry.id === model.id);
491
529
  if (!entry) {
492
530
  return undefined;
@@ -524,26 +562,28 @@ export async function discoverOpenAIModelsList(
524
562
  providerConfig.discovery.type === "lm-studio"
525
563
  ? fetchLmStudioNativeModelMetadata(baseUrl, ctx.fetch, { headers: h })
526
564
  : Promise.resolve(null);
527
- const [res, nativeMetadata] = await Promise.all([
528
- ctx.fetch(modelsUrl, {
529
- headers: h,
530
- signal: AbortSignal.timeout(10_000),
565
+ const [payload, nativeMetadata] = await Promise.all([
566
+ withTimeoutSignal(10_000, async signal => {
567
+ const res = await ctx.fetch(modelsUrl, {
568
+ headers: h,
569
+ signal,
570
+ });
571
+ if (!res.ok) {
572
+ throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
573
+ }
574
+ headers = h;
575
+ return (await res.json()) as {
576
+ data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>;
577
+ };
531
578
  }),
532
579
  nativeMetadataPromise,
533
580
  ]);
534
- if (!res.ok) {
535
- throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
536
- }
537
- headers = h;
538
- return [res, nativeMetadata] as const;
581
+ return [payload, nativeMetadata] as const;
539
582
  };
540
583
  const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
541
- const [response, nativeMetadata] = apiKey
584
+ const [payload, nativeMetadata] = apiKey
542
585
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
543
586
  : await attempt(baseHeaders);
544
- const payload = (await response.json()) as {
545
- data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>;
546
- };
547
587
  const models = payload.data ?? [];
548
588
  const discovered: Model<Api>[] = [];
549
589
  for (const item of models) {
@@ -600,15 +640,17 @@ export async function discoverLiteLLMModels(
600
640
  }
601
641
  return response;
602
642
  };
603
- const models = await fetchLiteLLMRichModels({
604
- api: providerConfig.api,
605
- provider: providerConfig.provider,
606
- baseUrl,
607
- headers: h,
608
- fetch: authAwareFetch,
609
- referenceResolver: resolveReference,
610
- signal: AbortSignal.timeout(10_000),
611
- });
643
+ const models = await withTimeoutSignal(10_000, signal =>
644
+ fetchLiteLLMRichModels({
645
+ api: providerConfig.api,
646
+ provider: providerConfig.provider,
647
+ baseUrl,
648
+ headers: h,
649
+ fetch: authAwareFetch,
650
+ referenceResolver: resolveReference,
651
+ signal,
652
+ }),
653
+ );
612
654
  if (authError && models === null) {
613
655
  throw authError;
614
656
  }
@@ -657,24 +699,24 @@ export async function discoverProxyModels(
657
699
 
658
700
  const baseHeaders: Record<string, string> = { ...(providerConfig.headers ?? {}) };
659
701
  let headers = baseHeaders;
660
- const attempt = async (h: Record<string, string>) => {
661
- const res = await ctx.fetch(modelsUrl, {
662
- headers: h,
663
- signal: AbortSignal.timeout(10_000),
702
+ const attempt = async (h: Record<string, string>) =>
703
+ withTimeoutSignal(10_000, async signal => {
704
+ const res = await ctx.fetch(modelsUrl, {
705
+ headers: h,
706
+ signal,
707
+ });
708
+ if (!res.ok) {
709
+ throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
710
+ }
711
+ headers = h;
712
+ return (await res.json()) as {
713
+ data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[]; context_length?: number }>;
714
+ };
664
715
  });
665
- if (!res.ok) {
666
- throw new Error(`HTTP ${res.status} from ${modelsUrl}`);
667
- }
668
- headers = h;
669
- return res;
670
- };
671
716
  const apiKey = await ctx.getBearerApiKeyResolver(providerConfig.provider);
672
- const response = apiKey
717
+ const payload = apiKey
673
718
  ? await withAuth(apiKey, key => attempt({ ...baseHeaders, Authorization: `Bearer ${key}` }))
674
719
  : await attempt(baseHeaders);
675
- const payload = (await response.json()) as {
676
- data?: Array<{ id?: string; name?: string; supported_endpoint_types?: string[]; context_length?: number }>;
677
- };
678
720
  const items = payload.data ?? [];
679
721
  const discovered: Model<Api>[] = [];
680
722
  for (const item of items) {
@@ -384,6 +384,83 @@ export function getSkillSlashCommandName(skill: Pick<Skill, "name">): string {
384
384
  return `skill:${skill.name}`;
385
385
  }
386
386
 
387
+ /**
388
+ * Parsed `/skill:<name>` invocation: either at the start of the draft (the
389
+ * traditional slash-command position) or as a `/skill:<name>` token embedded
390
+ * mid-prompt. For the mid-prompt form the surrounding prose is threaded
391
+ * through as `args` so the skill sees the full user request.
392
+ */
393
+ export interface ParsedSkillInvocation {
394
+ /** Bare skill name without the leading `skill:` prefix. */
395
+ name: string;
396
+ /** User-supplied arguments (everything outside the `/skill:<name>` token). */
397
+ args: string;
398
+ }
399
+
400
+ const MID_PROMPT_SKILL_RE = /(^|\s)\/skill:([^\s/]+)(\s|$)/;
401
+
402
+ /**
403
+ * Detect a `/skill:<name>` invocation in a user draft.
404
+ *
405
+ * Returns `undefined` when the text contains no skill token. Otherwise:
406
+ * - Leading form (`/skill:foo bar baz`): name=`foo`, args=`bar baz`.
407
+ * - Mid-prompt form (`fix the bug /skill:foo focus on auth`): name=`foo`,
408
+ * args=`fix the bug focus on auth` — the surrounding prose collapsed
409
+ * into a single args string.
410
+ *
411
+ * Mid-prompt detection is disabled when the draft itself starts with a
412
+ * different slash command (e.g. `/compact /skill:foo`) or a local-execution
413
+ * sigil — `!cmd` / `!!cmd` for the bash tool and `$ cmd` / `$$ cmd` for the
414
+ * python tool. Those handlers run after the skill-command dispatcher and
415
+ * their bodies routinely contain `/skill:<name>` references that are not
416
+ * meant as skill invocations.
417
+ */
418
+ export function parseSkillInvocation(text: string): ParsedSkillInvocation | undefined {
419
+ const trimmedStart = text.trimStart();
420
+ if (trimmedStart.startsWith("/skill:")) {
421
+ const spaceIndex = trimmedStart.indexOf(" ");
422
+ const name =
423
+ spaceIndex === -1 ? trimmedStart.slice("/skill:".length) : trimmedStart.slice("/skill:".length, spaceIndex);
424
+ if (!name) return undefined;
425
+ const args = spaceIndex === -1 ? "" : trimmedStart.slice(spaceIndex + 1).trim();
426
+ return { name, args };
427
+ }
428
+ if (trimmedStart.startsWith("/")) return undefined;
429
+ if (startsWithLocalExecutionPrefix(trimmedStart)) return undefined;
430
+ const match = MID_PROMPT_SKILL_RE.exec(text);
431
+ if (!match) return undefined;
432
+ const leading = match[1] ?? "";
433
+ const trailing = match[3] ?? "";
434
+ const tokenStart = match.index + leading.length;
435
+ const tokenEnd = match.index + match[0].length - trailing.length;
436
+ const name = match[2] ?? "";
437
+ if (!name) return undefined;
438
+ const before = text.slice(0, tokenStart).trimEnd();
439
+ const after = text.slice(tokenEnd).trimStart();
440
+ const args = [before, after]
441
+ .filter(part => part.length > 0)
442
+ .join(" ")
443
+ .trim();
444
+ return { name, args };
445
+ }
446
+
447
+ /**
448
+ * Whether the (already left-trimmed) draft begins with a TUI local-execution
449
+ * sigil that downstream branches will consume verbatim — `!`/`!!` for the bash
450
+ * tool and `$`/`$$` followed by ASCII whitespace for the python tool. Mirrors
451
+ * `pythonCommandPrefixLength` in `modes/controllers/input-controller` so the
452
+ * two checks agree without forcing a circular import.
453
+ */
454
+ function startsWithLocalExecutionPrefix(trimmedStart: string): boolean {
455
+ if (trimmedStart.startsWith("!")) return true;
456
+ if (trimmedStart.charCodeAt(0) !== 36 /* $ */) return false;
457
+ if (trimmedStart.charCodeAt(1) === 123 /* { */) return false;
458
+ const sigilLength = trimmedStart.charCodeAt(1) === 36 /* $ */ ? 2 : 1;
459
+ const next = trimmedStart.charCodeAt(sigilLength);
460
+ if (Number.isNaN(next)) return true;
461
+ return next === 32 /* space */ || next === 9 /* tab */ || next === 10 /* LF */ || next === 13 /* CR */;
462
+ }
463
+
387
464
  export async function buildSkillPromptMessage(
388
465
  skill: Pick<Skill, "name" | "filePath">,
389
466
  args: string,