@oh-my-pi/pi-coding-agent 16.1.3 → 16.1.5

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.
@@ -4,9 +4,9 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { theme } from "../../modes/theme/theme";
5
5
 
6
6
  /**
7
- * Minimum cached prefix (read + write) the previous turn must have established
8
- * before a collapse on the current turn counts as an invalidation. Filters out
9
- * tiny contexts and providers below the cacheable-prefix floor, where a zero
7
+ * Minimum prefix the previous turn must have READ back from cache before a
8
+ * collapse on the current turn counts as an invalidation. Filters out tiny
9
+ * contexts and providers below the cacheable-prefix floor, where a zero
10
10
  * `cacheRead` is expected rather than a reset.
11
11
  */
12
12
  const MIN_CACHE_FOOTPRINT = 2048;
@@ -18,25 +18,41 @@ export interface CacheInvalidation {
18
18
  }
19
19
 
20
20
  /**
21
- * Decide whether `current` turn lost the prompt cache that `prev` established.
21
+ * Decide whether `current` turn lost a *working* prompt cache that `prev` was
22
+ * reusing.
22
23
  *
23
24
  * The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/
24
25
  * system-prompt change (or a history rewrite) breaks the prefix, so the next
25
- * request reads nothing from cache and re-pays for the whole prompt. We detect
26
- * that as: the previous turn cached a meaningful prefix, yet this turn's
26
+ * request reads nothing from cache and re-pays for the whole prompt. We flag
27
+ * only the transition where a demonstrably warm cache goes cold: the previous
28
+ * turn must have actually READ a meaningful prefix back, and this turn's
27
29
  * `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt.
28
- * Returns `undefined` (no marker) for the first turn, tiny contexts, turns
29
- * that reused any cache, and crucially turns on providers with *implicit*
30
- * best-effort caching. Only an explicit, prefix-controlled cache (Anthropic /
31
- * Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite >
32
- * 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and
33
- * drop `cacheRead` to zero intermittently as routine propagation noise that
34
- * self-heals the next turn, so flagging it would be a false positive.
30
+ *
31
+ * Requiring a prior warm read is deliberate. A turn that merely WROTE the prefix
32
+ * (`cacheRead` 0) has not proven the cache is live — that is the session's first
33
+ * request, or a re-write after expiry so a following cold turn there is
34
+ * expected, not an invalidation the user caused (e.g. a long-running first tool
35
+ * call outliving the provider's 5-minute cache TTL surfaced a spurious "cache
36
+ * miss" right under the opening message). It also collapses a run of consecutive
37
+ * cold turns to the single marker at the moment the cache actually broke, instead
38
+ * of repeating the banner on every turn while it re-warms.
39
+ *
40
+ * Returns `undefined` (no marker) for the first turn, turns whose predecessor
41
+ * never read a warm prefix, tiny contexts, turns that reused any cache, and —
42
+ * crucially — turns on providers with *implicit* best-effort caching. Only an
43
+ * explicit, prefix-controlled cache (Anthropic / Bedrock `cache_control`)
44
+ * re-creates the prefix on a cold turn (`cacheWrite > 0`); implicit caches
45
+ * (Google / OpenAI / Fireworks) report `cacheWrite: 0` and drop `cacheRead` to
46
+ * zero intermittently as routine propagation noise that self-heals the next
47
+ * turn, so flagging it would be a false positive.
35
48
  */
36
49
  export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
37
50
  if (!prev) return undefined;
38
- const prevFootprint = prev.cacheRead + prev.cacheWrite;
39
- if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined;
51
+ // Only flag a warm→cold transition: the previous turn must have actually read
52
+ // a meaningful prefix from cache. A write-only predecessor (first request, or
53
+ // a re-write after expiry) has not proven the cache is live, so a cold turn
54
+ // behind it is expected — not an invalidation worth surfacing.
55
+ if (prev.cacheRead < MIN_CACHE_FOOTPRINT) return undefined;
40
56
  // Any cache reuse this turn means the prefix survived (at least partly).
41
57
  if (current.cacheRead > 0) return undefined;
42
58
  // Only an explicit, prefix-controlled cache re-creates the prefix on a cold
@@ -39,11 +39,12 @@ function feedGaps(editor: CustomEditor, gaps: number[]): void {
39
39
  }
40
40
  }
41
41
 
42
- async function decorateInFreshProcess(text: string): Promise<string> {
42
+ async function decorateInFreshProcess(text: string, imageLinks?: readonly string[]): Promise<string> {
43
43
  const customEditorUrl = new URL("./custom-editor.ts", import.meta.url).href;
44
44
  const script = `
45
45
  import { CustomEditor } from ${JSON.stringify(customEditorUrl)};
46
46
  const editor = new CustomEditor({});
47
+ editor.imageLinks = ${JSON.stringify(imageLinks)};
47
48
  process.stdout.write(editor.decorateText(${JSON.stringify(text)}));
48
49
  `;
49
50
  const child = await $`bun -e ${script}`.quiet().nothrow();
@@ -59,8 +60,8 @@ describe("CustomEditor placeholder decoration", () => {
59
60
  expect(output).toBe("[Paste #1, +30 lines]");
60
61
  });
61
62
 
62
- it("renders image placeholders before theme initialization", async () => {
63
- const output = await decorateInFreshProcess("[Image #1]");
63
+ it("renders linked image placeholders before theme and settings initialization", async () => {
64
+ const output = await decorateInFreshProcess("[Image #1]", ["/tmp/example.png"]);
64
65
  expect(output).toBe("[Image #1]");
65
66
  });
66
67
  });
@@ -154,6 +154,8 @@ interface ContextUsageMemo {
154
154
  }
155
155
 
156
156
  const EMPTY_MESSAGES: readonly AgentMessage[] = [];
157
+ const STATUS_USAGE_START_DELAY_MS = 0;
158
+ const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
157
159
 
158
160
  function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
159
161
  return segments.includes("context_pct") || segments.includes("context_total");
@@ -212,6 +214,7 @@ export class StatusLineComponent implements Component {
212
214
  } | null = null;
213
215
  #usageFetchedAt = 0;
214
216
  #usageInFlight = false;
217
+ #usageStartTimer: Timer | null = null;
215
218
  // Context-usage memo. The status line redraws on every agent event, so the
216
219
  // hot path must not recompute context tokens unless an input changed.
217
220
  // `getContextUsage()` anchors on the last assistant's real prompt-token
@@ -344,16 +347,24 @@ export class StatusLineComponent implements Component {
344
347
  dispose(): void {
345
348
  this.#disposed = true;
346
349
  this.#onBranchChange = null;
350
+ this.#clearUsageStartTimer();
347
351
  if (this.#gitWatcher) {
348
352
  this.#gitWatcher.close();
349
353
  this.#gitWatcher = null;
350
354
  }
351
355
  }
352
356
 
357
+ #clearUsageStartTimer(): void {
358
+ if (!this.#usageStartTimer) return;
359
+ clearTimeout(this.#usageStartTimer);
360
+ this.#usageStartTimer = null;
361
+ }
362
+
353
363
  invalidate(): void {
354
364
  this.#invalidateGitCaches();
355
365
  }
356
366
  #invalidateSessionCaches(): void {
367
+ this.#clearUsageStartTimer();
357
368
  this.#cachedUsage = null;
358
369
  this.#usageFetchedAt = 0;
359
370
  this.#usageInFlight = false;
@@ -521,38 +532,73 @@ export class StatusLineComponent implements Component {
521
532
  }
522
533
 
523
534
  /**
524
- * Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
525
- * TTL on both success (cache lifetime) and error (backoff). Exposed
526
- * (non-private) so unit tests can verify the backoff invariant.
535
+ * Startup redraws only arm a short-delayed task; timeout releases the render
536
+ * cadence while a late successful fetch can still refresh the cached segment.
527
537
  */
528
538
  refreshUsageInBackground(): void {
529
539
  const now = Date.now();
530
- if (this.#usageInFlight) return;
540
+ if (this.#usageInFlight || this.#usageStartTimer) return;
531
541
  if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
532
542
  const session = this.session;
533
- const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
543
+ const fetcher = (session as { fetchUsageReports?: (signal?: AbortSignal) => Promise<unknown> }).fetchUsageReports;
534
544
  if (typeof fetcher !== "function") return;
535
545
  this.#usageInFlight = true;
536
- void fetcher
537
- .call(session)
546
+ this.#usageStartTimer = setTimeout(() => {
547
+ this.#usageStartTimer = null;
548
+ void this.#runUsageRefresh(session, fetcher);
549
+ }, STATUS_USAGE_START_DELAY_MS);
550
+ }
551
+
552
+ async #runUsageRefresh(session: AgentSession, fetcher: (signal?: AbortSignal) => Promise<unknown>): Promise<void> {
553
+ if (this.#disposed || this.session !== session) {
554
+ this.#usageInFlight = false;
555
+ return;
556
+ }
557
+ const signal = AbortSignal.timeout(STATUS_USAGE_REFRESH_TIMEOUT_MS);
558
+ let reportsPromise: Promise<unknown> | undefined;
559
+ try {
560
+ reportsPromise = fetcher.call(session, signal);
561
+ this.#applyUsageRefreshReports(session, await this.#raceUsageRefreshWithSignal(reportsPromise, signal));
562
+ } catch {
563
+ if (this.session !== session) return;
564
+ this.#usageFetchedAt = Date.now();
565
+ if (signal.aborted && reportsPromise) {
566
+ this.#observeLateUsageRefresh(session, reportsPromise);
567
+ }
568
+ } finally {
569
+ if (this.session === session) this.#usageInFlight = false;
570
+ }
571
+ }
572
+
573
+ #applyUsageRefreshReports(session: AgentSession, reports: unknown): void {
574
+ if (this.#disposed || this.session !== session) return;
575
+ this.#cachedUsage = this.#normalizeUsageReports(reports);
576
+ this.#usageFetchedAt = Date.now();
577
+ }
578
+
579
+ #observeLateUsageRefresh(session: AgentSession, reportsPromise: Promise<unknown>): void {
580
+ void reportsPromise
538
581
  .then(reports => {
539
- if (this.session !== session) return;
540
- this.#cachedUsage = this.#normalizeUsageReports(reports);
541
- this.#usageFetchedAt = Date.now();
582
+ this.#applyUsageRefreshReports(session, reports);
542
583
  })
543
584
  .catch(() => {
544
- if (this.session !== session) return;
545
- // Backoff on error: stamp the fetch time so the 5-min TTL guard
546
- // also acts as an error budget. Without this, every render
547
- // kicks off another fetch (gated only by #usageInFlight),
548
- // which hammers the endpoint during a network outage / 5xx.
585
+ if (this.#disposed || this.session !== session) return;
549
586
  this.#usageFetchedAt = Date.now();
550
- })
551
- .finally(() => {
552
- if (this.session === session) this.#usageInFlight = false;
553
587
  });
554
588
  }
555
589
 
590
+ async #raceUsageRefreshWithSignal(promise: Promise<unknown>, signal: AbortSignal): Promise<unknown> {
591
+ if (signal.aborted) throw signal.reason;
592
+ const aborted = Promise.withResolvers<never>();
593
+ const onAbort = () => aborted.reject(signal.reason);
594
+ signal.addEventListener("abort", onAbort, { once: true });
595
+ try {
596
+ return await Promise.race([promise, aborted.promise]);
597
+ } finally {
598
+ signal.removeEventListener("abort", onAbort);
599
+ }
600
+ }
601
+
556
602
  #normalizeUsageReports(reports: unknown): {
557
603
  fiveHour?: { percent: number; resetMinutes?: number };
558
604
  sevenDay?: { percent: number; resetHours?: number };
package/src/sdk.ts CHANGED
@@ -687,6 +687,37 @@ export async function loadSessionExtensions(
687
687
  return result;
688
688
  }
689
689
 
690
+ /**
691
+ * Load discovered/configured extensions and register their providers into
692
+ * `modelRegistry`, then discover the dynamic provider catalogs. One-shot CLIs
693
+ * (`omp bench`, dry-balance) build a bare {@link ModelRegistry} that only knows
694
+ * built-in catalog providers; without this, providers contributed by an
695
+ * extension (e.g. a custom OpenAI-compatible provider under
696
+ * `~/.omp/agent/extensions/`) never reach model resolution. Mirrors the
697
+ * session / `omp models` path: drain the queued provider registrations, then
698
+ * `refreshRuntimeProviders` so dynamically-discovered models exist before
699
+ * selectors are resolved.
700
+ */
701
+ export async function loadCliExtensionProviders(
702
+ modelRegistry: ModelRegistry,
703
+ settings: Settings,
704
+ cwd: string,
705
+ options: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths"> = {},
706
+ ): Promise<void> {
707
+ const eventBus = new EventBus();
708
+ const extensionsResult = await loadSessionExtensions(options, cwd, settings, eventBus);
709
+ const activeSources = extensionsResult.extensions.map(extension => extension.path);
710
+ modelRegistry.syncExtensionSources(activeSources);
711
+ for (const sourceId of new Set(activeSources)) {
712
+ modelRegistry.clearSourceRegistrations(sourceId);
713
+ }
714
+ for (const { name, config, sourceId } of extensionsResult.runtime.pendingProviderRegistrations) {
715
+ modelRegistry.registerProvider(name, config, sourceId);
716
+ }
717
+ extensionsResult.runtime.pendingProviderRegistrations = [];
718
+ await modelRegistry.refreshRuntimeProviders();
719
+ }
720
+
690
721
  /**
691
722
  * Discover skills from cwd and agentDir.
692
723
  */
@@ -1518,6 +1549,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1518
1549
  timestamp: Date.now(),
1519
1550
  }),
1520
1551
  peekQueueInvoker: () => session.peekQueueInvoker(),
1552
+ peekPendingInvoker: () => session.peekPendingInvoker(),
1553
+ clearPendingInvokers: () => session.clearPendingInvokers(),
1521
1554
  peekStandingResolveHandler: () => session.peekStandingResolveHandler(),
1522
1555
  setStandingResolveHandler: handler => session.setStandingResolveHandler(handler),
1523
1556
  allocateOutputArtifact: async toolType => {
@@ -2141,8 +2141,9 @@ export class AgentSession {
2141
2141
  return this.#agentId;
2142
2142
  }
2143
2143
 
2144
- /** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
2145
- nextToolChoice(): ToolChoice | undefined {
2144
+ /** Dequeue the next HARD forced tool choice for the upcoming LLM call, dropping
2145
+ * (and rejecting) one whose named tool is no longer active. */
2146
+ #nextHardToolChoice(): ToolChoice | undefined {
2146
2147
  const choice = this.#toolChoiceQueue.nextToolChoice();
2147
2148
  if (isToolChoiceActive(choice, this.agent.state.tools)) {
2148
2149
  return choice;
@@ -2154,7 +2155,7 @@ export class AgentSession {
2154
2155
  /**
2155
2156
  * The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority:
2156
2157
  * 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) —
2157
- * consuming, unchanged from `nextToolChoice`;
2158
+ * consuming (advances the queue generator);
2158
2159
  * 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a
2159
2160
  * PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head
2160
2161
  * and escalates to a forced `resolve` only if the model declines. A compliant turn
@@ -2162,7 +2163,7 @@ export class AgentSession {
2162
2163
  * 3. else undefined.
2163
2164
  */
2164
2165
  nextToolChoiceDirective(): ToolChoiceDirective | undefined {
2165
- const hard = this.nextToolChoice();
2166
+ const hard = this.#nextHardToolChoice();
2166
2167
  if (hard !== undefined) return hard;
2167
2168
  const head = this.#toolChoiceQueue.peekPendingHead();
2168
2169
  if (head !== undefined) {
@@ -2181,6 +2182,11 @@ export class AgentSession {
2181
2182
  return this.#toolChoiceQueue.peekPendingInvoker();
2182
2183
  }
2183
2184
 
2185
+ /** Clear stale non-forcing pending preview invokers after `resolve` proves none can run. */
2186
+ clearPendingInvokers(): void {
2187
+ this.#toolChoiceQueue.clearPendingInvokers();
2188
+ }
2189
+
2184
2190
  /**
2185
2191
  * Force the next model call to target a specific active tool, then terminate
2186
2192
  * the agent loop. Pushes a two-step sequence [forced, "none"] so the model
@@ -231,6 +231,12 @@ export class ToolChoiceQueue {
231
231
  this.#pendingInvokers = this.#pendingInvokers.filter(p => p.id !== id);
232
232
  }
233
233
 
234
+ /** Drop every pending preview invoker without touching hard tool-choice directives. */
235
+ clearPendingInvokers(): void {
236
+ if (this.#pendingInvokers.length === 0) return;
237
+ this.#pendingInvokers = [];
238
+ }
239
+
234
240
  /** True when at least one non-forcing pending preview is registered. */
235
241
  get hasPendingInvoker(): boolean {
236
242
  return this.#pendingInvokers.length > 0;
@@ -316,6 +316,8 @@ export interface ToolSession {
316
316
  * tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the
317
317
  * agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */
318
318
  peekPendingInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
319
+ /** Clear stale pending preview markers when `resolve` cannot dispatch them. */
320
+ clearPendingInvokers?(): void;
319
321
  /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
320
322
  * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
321
323
  * letting modes accept `resolve` invocations without forcing the tool choice every turn. */
@@ -212,6 +212,7 @@ export class ResolveTool implements AgentTool<typeof resolveSchema, ResolveToolD
212
212
  this.session.peekPendingInvoker?.() ??
213
213
  this.session.peekStandingResolveHandler?.();
214
214
  if (!invoker) {
215
+ this.session.clearPendingInvokers?.();
215
216
  // `discard` is a request to cancel/abort a staged action. When nothing is
216
217
  // pending, the desired end-state (no staged change) already holds, so honor
217
218
  // it as a successful cancellation instead of surfacing a hard error to the
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import * as url from "node:url";
9
9
  import { TERMINAL } from "@oh-my-pi/pi-tui";
10
- import { settings } from "../config/settings";
10
+ import { isSettingsInitialized, settings } from "../config/settings";
11
11
  import {
12
12
  LocalProtocolHandler,
13
13
  memoryRootsFromRegistry,
@@ -45,8 +45,10 @@ function buildFileUri(filePath: string, opts?: { line?: number; col?: number }):
45
45
  * - `"off"`: never
46
46
  * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
47
47
  * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
48
+ * Before settings initialization, returns false so early render paths stay plain text.
48
49
  */
49
50
  export function isHyperlinkEnabled(): boolean {
51
+ if (!isSettingsInitialized()) return false;
50
52
  const mode = settings.get("tui.hyperlinks");
51
53
  if (mode === "off") return false;
52
54
  if (mode === "always") return true;
@@ -104,10 +106,11 @@ export function urlHyperlink(url: string, displayText: string): string {
104
106
  * Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
105
107
  * bypassing terminal capability auto-detection. Used for auth prompts where
106
108
  * an inert "click" label blocks login on terminals whose capabilities are
107
- * not advertised. Still returns plain text when the user has explicitly
108
- * opted out via `tui.hyperlinks=off`.
109
+ * not advertised. Still returns plain text before settings initialization or
110
+ * when the user has explicitly opted out via `tui.hyperlinks=off`.
109
111
  */
110
112
  export function urlHyperlinkAlways(url: string, displayText: string): string {
113
+ if (!isSettingsInitialized()) return displayText;
111
114
  if (settings.get("tui.hyperlinks") === "off") return displayText;
112
115
  const normalized = url.match(/^www\./i) ? `https://${url}` : url;
113
116
  try {