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

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.
@@ -6,20 +6,33 @@ export interface CacheInvalidation {
6
6
  reprocessedTokens: number;
7
7
  }
8
8
  /**
9
- * Decide whether `current` turn lost the prompt cache that `prev` established.
9
+ * Decide whether `current` turn lost a *working* prompt cache that `prev` was
10
+ * reusing.
10
11
  *
11
12
  * The provider reports a warm prefix as `cacheRead`; a model/thinking/tool/
12
13
  * system-prompt change (or a history rewrite) breaks the prefix, so the next
13
- * request reads nothing from cache and re-pays for the whole prompt. We detect
14
- * that as: the previous turn cached a meaningful prefix, yet this turn's
14
+ * request reads nothing from cache and re-pays for the whole prompt. We flag
15
+ * only the transition where a demonstrably warm cache goes cold: the previous
16
+ * turn must have actually READ a meaningful prefix back, and this turn's
15
17
  * `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt.
16
- * Returns `undefined` (no marker) for the first turn, tiny contexts, turns
17
- * that reused any cache, and crucially turns on providers with *implicit*
18
- * best-effort caching. Only an explicit, prefix-controlled cache (Anthropic /
19
- * Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite >
20
- * 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and
21
- * drop `cacheRead` to zero intermittently as routine propagation noise that
22
- * self-heals the next turn, so flagging it would be a false positive.
18
+ *
19
+ * Requiring a prior warm read is deliberate. A turn that merely WROTE the prefix
20
+ * (`cacheRead` 0) has not proven the cache is live — that is the session's first
21
+ * request, or a re-write after expiry so a following cold turn there is
22
+ * expected, not an invalidation the user caused (e.g. a long-running first tool
23
+ * call outliving the provider's 5-minute cache TTL surfaced a spurious "cache
24
+ * miss" right under the opening message). It also collapses a run of consecutive
25
+ * cold turns to the single marker at the moment the cache actually broke, instead
26
+ * of repeating the banner on every turn while it re-warms.
27
+ *
28
+ * Returns `undefined` (no marker) for the first turn, turns whose predecessor
29
+ * never read a warm prefix, tiny contexts, turns that reused any cache, and —
30
+ * crucially — turns on providers with *implicit* best-effort caching. Only an
31
+ * explicit, prefix-controlled cache (Anthropic / Bedrock `cache_control`)
32
+ * re-creates the prefix on a cold turn (`cacheWrite > 0`); implicit caches
33
+ * (Google / OpenAI / Fireworks) report `cacheWrite: 0` and drop `cacheRead` to
34
+ * zero intermittently as routine propagation noise that self-heals the next
35
+ * turn, so flagging it would be a false positive.
23
36
  */
24
37
  export declare function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined;
25
38
  /**
@@ -34,9 +34,8 @@ export declare class StatusLineComponent implements Component {
34
34
  dispose(): void;
35
35
  invalidate(): void;
36
36
  /**
37
- * Background-refresh the Anthropic OAuth quota report. Guarded by a 5-min
38
- * TTL on both success (cache lifetime) and error (backoff). Exposed
39
- * (non-private) so unit tests can verify the backoff invariant.
37
+ * Startup redraws only arm a short-delayed task; timeout releases the render
38
+ * cadence while a late successful fetch can still refresh the cached segment.
40
39
  */
41
40
  refreshUsageInBackground(): void;
42
41
  /**
@@ -262,6 +262,18 @@ export declare function discoverSessionExtensionPaths(options: Pick<CreateAgentS
262
262
  * repeated. Keep this the single source of the discovery branch logic.
263
263
  */
264
264
  export declare function loadSessionExtensions(options: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths">, cwd: string, settings: Settings, eventBus: EventBus): Promise<LoadExtensionsResult>;
265
+ /**
266
+ * Load discovered/configured extensions and register their providers into
267
+ * `modelRegistry`, then discover the dynamic provider catalogs. One-shot CLIs
268
+ * (`omp bench`, dry-balance) build a bare {@link ModelRegistry} that only knows
269
+ * built-in catalog providers; without this, providers contributed by an
270
+ * extension (e.g. a custom OpenAI-compatible provider under
271
+ * `~/.omp/agent/extensions/`) never reach model resolution. Mirrors the
272
+ * session / `omp models` path: drain the queued provider registrations, then
273
+ * `refreshRuntimeProviders` so dynamically-discovered models exist before
274
+ * selectors are resolved.
275
+ */
276
+ export declare function loadCliExtensionProviders(modelRegistry: ModelRegistry, settings: Settings, cwd: string, options?: Pick<CreateAgentSessionOptions, "disableExtensionDiscovery" | "additionalExtensionPaths">): Promise<void>;
265
277
  /**
266
278
  * Discover skills from cwd and agentDir.
267
279
  */
@@ -399,6 +399,8 @@ export declare class AgentSession {
399
399
  nextToolChoiceDirective(): ToolChoiceDirective | undefined;
400
400
  /** Peek the head non-forcing pending preview invoker, for the `resolve` tool's dispatch. */
401
401
  peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
402
+ /** Clear stale non-forcing pending preview invokers after `resolve` proves none can run. */
403
+ clearPendingInvokers(): void;
402
404
  /**
403
405
  * Force the next model call to target a specific active tool, then terminate
404
406
  * the agent loop. Pushes a two-step sequence [forced, "none"] so the model
@@ -71,6 +71,8 @@ export declare class ToolChoiceQueue {
71
71
  registerPendingInvoker(id: string, sourceToolName: string, onInvoked: (input: unknown) => Promise<unknown> | unknown): void;
72
72
  /** Drop the pending invoker with this id (e.g. after it resolves). */
73
73
  removePendingInvoker(id: string): void;
74
+ /** Drop every pending preview invoker without touching hard tool-choice directives. */
75
+ clearPendingInvokers(): void;
74
76
  /** True when at least one non-forcing pending preview is registered. */
75
77
  get hasPendingInvoker(): boolean;
76
78
  /** The head (most-recently registered) pending invoker's handler, for resolve dispatch. */
@@ -276,6 +276,8 @@ export interface ToolSession {
276
276
  * tool dispatches to it so a staged preview resolves WITHOUT forcing tool_choice — the
277
277
  * agent-loop's SoftToolRequirement lifecycle owns reminder injection and escalation. */
278
278
  peekPendingInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
279
+ /** Clear stale pending preview markers when `resolve` cannot dispatch them. */
280
+ clearPendingInvokers?(): void;
279
281
  /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
280
282
  * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
281
283
  * letting modes accept `resolve` invocations without forcing the tool choice every turn. */
@@ -5,6 +5,7 @@
5
5
  * - `"off"`: never
6
6
  * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support
7
7
  * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it)
8
+ * Before settings initialization, returns false so early render paths stay plain text.
8
9
  */
9
10
  export declare function isHyperlinkEnabled(): boolean;
10
11
  /**
@@ -23,8 +24,8 @@ export declare function urlHyperlink(url: string, displayText: string): string;
23
24
  * Wrap `displayText` in an OSC 8 hyperlink pointing at an HTTP(S) URL,
24
25
  * bypassing terminal capability auto-detection. Used for auth prompts where
25
26
  * an inert "click" label blocks login on terminals whose capabilities are
26
- * not advertised. Still returns plain text when the user has explicitly
27
- * opted out via `tui.hyperlinks=off`.
27
+ * not advertised. Still returns plain text before settings initialization or
28
+ * when the user has explicitly opted out via `tui.hyperlinks=off`.
28
29
  */
29
30
  export declare function urlHyperlinkAlways(url: string, displayText: string): string;
30
31
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.1.3",
4
+ "version": "16.1.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -48,17 +48,17 @@
48
48
  "@agentclientprotocol/sdk": "0.25.0",
49
49
  "@babel/parser": "^7.29.7",
50
50
  "@mozilla/readability": "^0.6.0",
51
- "@oh-my-pi/hashline": "16.1.3",
52
- "@oh-my-pi/omp-stats": "16.1.3",
53
- "@oh-my-pi/pi-agent-core": "16.1.3",
54
- "@oh-my-pi/pi-ai": "16.1.3",
55
- "@oh-my-pi/pi-catalog": "16.1.3",
56
- "@oh-my-pi/pi-mnemopi": "16.1.3",
57
- "@oh-my-pi/pi-natives": "16.1.3",
58
- "@oh-my-pi/pi-tui": "16.1.3",
59
- "@oh-my-pi/pi-utils": "16.1.3",
60
- "@oh-my-pi/pi-wire": "16.1.3",
61
- "@oh-my-pi/snapcompact": "16.1.3",
51
+ "@oh-my-pi/hashline": "16.1.4",
52
+ "@oh-my-pi/omp-stats": "16.1.4",
53
+ "@oh-my-pi/pi-agent-core": "16.1.4",
54
+ "@oh-my-pi/pi-ai": "16.1.4",
55
+ "@oh-my-pi/pi-catalog": "16.1.4",
56
+ "@oh-my-pi/pi-mnemopi": "16.1.4",
57
+ "@oh-my-pi/pi-natives": "16.1.4",
58
+ "@oh-my-pi/pi-tui": "16.1.4",
59
+ "@oh-my-pi/pi-utils": "16.1.4",
60
+ "@oh-my-pi/pi-wire": "16.1.4",
61
+ "@oh-my-pi/snapcompact": "16.1.4",
62
62
  "@opentelemetry/api": "^1.9.1",
63
63
  "@opentelemetry/context-async-hooks": "^2.7.1",
64
64
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -25,7 +25,7 @@ import {
25
25
  } from "../config/model-resolver";
26
26
  import { Settings } from "../config/settings";
27
27
  import benchPrompt from "../prompts/bench.md" with { type: "text" };
28
- import { discoverAuthStorage } from "../sdk";
28
+ import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
29
29
  import { resolveThinkingLevelForModel, shouldDisableReasoning, toReasoningEffort } from "../thinking";
30
30
 
31
31
  const DEFAULT_RUNS = 1;
@@ -145,6 +145,23 @@ function isFirstTokenEvent(event: AssistantMessageEvent): boolean {
145
145
  }
146
146
  }
147
147
 
148
+ /** Final message carries visible output — non-empty text/thinking or a tool call. */
149
+ function hasVisibleFinalContent(message: AssistantMessage): boolean {
150
+ return message.content.some(block => {
151
+ switch (block.type) {
152
+ case "text":
153
+ return block.text.length > 0;
154
+ case "thinking":
155
+ return block.thinking.length > 0;
156
+ case "redactedThinking":
157
+ case "toolCall":
158
+ return true;
159
+ default:
160
+ return false;
161
+ }
162
+ });
163
+ }
164
+
148
165
  /**
149
166
  * Tokens/s over the generation window (duration minus TTFT) so queue/prefill
150
167
  * latency does not dilute throughput. Falls back to total duration when the
@@ -232,6 +249,18 @@ async function runBenchRequest(
232
249
  const rawTtft = message.ttft ?? (firstTokenAt === undefined ? durationMs : firstTokenAt - startedAt);
233
250
  const ttftMs = Number.isFinite(rawTtft) && rawTtft > 0 ? rawTtft : 0;
234
251
  const outputTokens = Number.isFinite(message.usage.output) && message.usage.output > 0 ? message.usage.output : 0;
252
+ // A run that streamed no content (no delta/end event set firstTokenAt),
253
+ // carries no visible final content, and measured no output tokens
254
+ // benchmarked nothing — a genuinely empty stream (e.g. a gateway that 200s
255
+ // with an empty body). Surface it as a failure instead of a misleading
256
+ // 0-token "✓". Streaming and buffered providers that produce content keep
257
+ // passing even when usage is omitted.
258
+ if (firstTokenAt === undefined && outputTokens === 0 && !hasVisibleFinalContent(message)) {
259
+ return {
260
+ ok: false,
261
+ error: `provider returned no output (0 tokens, empty stream; stop reason: ${message.stopReason ?? "unknown"})`,
262
+ };
263
+ }
235
264
  return {
236
265
  ok: true,
237
266
  ttftMs,
@@ -328,8 +357,10 @@ export function formatBenchTable(summary: BenchSummary): string {
328
357
  async function createDefaultRuntime(): Promise<BenchRuntime> {
329
358
  const authStorage = await discoverAuthStorage();
330
359
  try {
331
- const settings = await Settings.init({ cwd: getProjectDir() });
360
+ const cwd = getProjectDir();
361
+ const settings = await Settings.init({ cwd });
332
362
  const modelRegistry = new ModelRegistry(authStorage);
363
+ await loadCliExtensionProviders(modelRegistry, settings, cwd);
333
364
  return {
334
365
  modelRegistry,
335
366
  settings,
@@ -26,7 +26,7 @@ import {
26
26
  } from "../config/model-resolver";
27
27
  import { Settings } from "../config/settings";
28
28
  import dryBalanceBenchPrompt from "../prompts/dry-balance-bench.md" with { type: "text" };
29
- import { discoverAuthStorage } from "../sdk";
29
+ import { discoverAuthStorage, loadCliExtensionProviders } from "../sdk";
30
30
 
31
31
  const DEFAULT_SAMPLE_COUNT = 100;
32
32
  const DEFAULT_CONCURRENCY = 32;
@@ -523,8 +523,10 @@ async function runBenchTargets(
523
523
  async function createDefaultRuntime(): Promise<DryBalanceRuntime> {
524
524
  const authStorage = await discoverAuthStorage();
525
525
  try {
526
- const settings = await Settings.init({ cwd: getProjectDir() });
526
+ const cwd = getProjectDir();
527
+ const settings = await Settings.init({ cwd });
527
528
  const modelRegistry = new ModelRegistry(authStorage);
529
+ await loadCliExtensionProviders(modelRegistry, settings, cwd);
528
530
  return {
529
531
  modelRegistry,
530
532
  settings,
@@ -248,11 +248,29 @@ export class PluginManager {
248
248
  }
249
249
 
250
250
  async #rollbackFailedInstall(
251
- actualName: string,
251
+ actualName: string | undefined,
252
252
  packageJsonBefore: string,
253
+ bunLockBefore: string | null,
253
254
  snapshot: PluginPackageSnapshot | null,
254
255
  ): Promise<void> {
255
256
  await Bun.write(getPluginsPackageJson(), packageJsonBefore);
257
+
258
+ // Restore (or remove) bun's lockfile. Without this, a `bun install` +
259
+ // `bun update` pair that successfully rewrote `bun.lock` would leave the
260
+ // rejected commit pinned even when validation rolls everything else back.
261
+ const bunLockPath = path.join(getPluginsDir(), "bun.lock");
262
+ if (bunLockBefore === null) {
263
+ await fs.promises.rm(bunLockPath, { force: true });
264
+ } else {
265
+ await Bun.write(bunLockPath, bunLockBefore);
266
+ }
267
+
268
+ // `actualName` may be undefined when the install failed before the dep
269
+ // key was resolved — package.json + bun.lock restoration above is the
270
+ // complete rollback in that case.
271
+ if (!actualName) {
272
+ return;
273
+ }
256
274
  const packagePath = path.join(getPluginsNodeModules(), actualName);
257
275
  await fs.promises.rm(packagePath, { recursive: true, force: true });
258
276
  if (!snapshot) {
@@ -343,6 +361,19 @@ export class PluginManager {
343
361
  }
344
362
  const pkgJsonPath = getPluginsPackageJson();
345
363
  const packageJsonBefore = await Bun.file(pkgJsonPath).text();
364
+ // Snapshot bun's lockfile so the rollback path can restore the pin. Every
365
+ // step below — `bun install`, `bun update`, feature/extension validation,
366
+ // runtime-config save — must either complete entirely or leave the
367
+ // lockfile pointing at its pre-install state. Absent before install means
368
+ // "remove on rollback".
369
+ const bunLockPath = path.join(getPluginsDir(), "bun.lock");
370
+ let bunLockBefore: string | null;
371
+ try {
372
+ bunLockBefore = await Bun.file(bunLockPath).text();
373
+ } catch (err) {
374
+ if (!isEnoent(err)) throw err;
375
+ bunLockBefore = null;
376
+ }
346
377
  const depsBefore = await this.#readDeps(pkgJsonPath);
347
378
  const packageInstallSpec = gitSource ? gitInstallSpec(spec.packageName, gitSource) : spec.packageName;
348
379
  const existingActualName = gitSource
@@ -350,24 +381,26 @@ export class PluginManager {
350
381
  : extractPackageName(spec.packageName);
351
382
  const packageSnapshot = await this.#snapshotInstalledPackage(existingActualName);
352
383
 
384
+ // `actualName` is hoisted so the rollback handler can clean up the right
385
+ // node_modules entry even if a step between `bun install` and the final
386
+ // validation throws.
387
+ let actualName: string | undefined;
353
388
  try {
354
- // Run npm install
355
- const proc = Bun.spawn(["bun", "install", packageInstallSpec], {
389
+ // Step 1: write the spec into plugins/package.json + node_modules.
390
+ const installProc = Bun.spawn(["bun", "install", packageInstallSpec], {
356
391
  cwd: getPluginsDir(),
357
392
  stdin: "ignore",
358
393
  stdout: "pipe",
359
394
  stderr: "pipe",
360
395
  windowsHide: true,
361
396
  });
362
-
363
- const exitCode = await proc.exited;
364
- if (exitCode !== 0) {
365
- const stderr = await new Response(proc.stderr).text();
366
- throw new Error(`npm install failed: ${stderr}`);
397
+ const installExit = await installProc.exited;
398
+ if (installExit !== 0) {
399
+ const stderr = await new Response(installProc.stderr).text();
400
+ throw new Error(`bun install failed: ${stderr}`);
367
401
  }
368
402
  // Resolve actual package name. npm specs encode the name (strip version);
369
403
  // git specs do not, so diff plugins/package.json deps to find the new entry.
370
- let actualName: string;
371
404
  if (gitSource) {
372
405
  const depsAfter = await this.#readDeps(pkgJsonPath);
373
406
  let resolved: string | undefined;
@@ -393,8 +426,32 @@ export class PluginManager {
393
426
  } else {
394
427
  actualName = extractPackageName(spec.packageName);
395
428
  }
396
- const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json");
397
429
 
430
+ // Step 2: refresh the git lockfile pin when re-installing an existing
431
+ // git plugin. `bun install <spec>` is a no-op when the spec matches the
432
+ // lockfile entry — it never re-resolves the remote ref — so re-running
433
+ // `omp plugin install github:owner/repo` would silently keep the user on
434
+ // the original resolved commit even after upstream moved (#3063).
435
+ // `bun update <name>` re-resolves the ref against the remote and
436
+ // rewrites the pin; SHA-pinned refs stay put because the commit can't
437
+ // move. First-time installs skip this — the initial `bun install` already
438
+ // fetched HEAD. Rollback is handled by the outer catch.
439
+ if (gitSource && existingActualName) {
440
+ const updateProc = Bun.spawn(["bun", "update", actualName], {
441
+ cwd: getPluginsDir(),
442
+ stdin: "ignore",
443
+ stdout: "pipe",
444
+ stderr: "pipe",
445
+ windowsHide: true,
446
+ });
447
+ const updateExit = await updateProc.exited;
448
+ if (updateExit !== 0) {
449
+ const stderr = await new Response(updateProc.stderr).text();
450
+ throw new Error(`bun update ${actualName} failed: ${stderr}`);
451
+ }
452
+ }
453
+
454
+ const pkgPath = path.join(getPluginsNodeModules(), actualName, "package.json");
398
455
  let pkg: { name: string; version: string; omp?: PluginManifest; pi?: PluginManifest };
399
456
  try {
400
457
  pkg = await Bun.file(pkgPath).json();
@@ -441,18 +498,7 @@ export class PluginManager {
441
498
  enabled: true,
442
499
  };
443
500
 
444
- try {
445
- await this.#validateInstalledExtensions(installedPlugin);
446
- } catch (err) {
447
- try {
448
- await this.#rollbackFailedInstall(actualName, packageJsonBefore, packageSnapshot);
449
- } catch (rollbackErr) {
450
- const message = err instanceof Error ? err.message : String(err);
451
- const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
452
- throw new Error(`${message}\nRollback failed: ${rollbackMessage}`);
453
- }
454
- throw err;
455
- }
501
+ await this.#validateInstalledExtensions(installedPlugin);
456
502
 
457
503
  // Update runtime config
458
504
  const config = await this.#ensureConfigLoaded();
@@ -464,6 +510,20 @@ export class PluginManager {
464
510
  await this.#saveRuntimeConfig();
465
511
 
466
512
  return installedPlugin;
513
+ } catch (err) {
514
+ try {
515
+ await this.#rollbackFailedInstall(
516
+ actualName ?? existingActualName,
517
+ packageJsonBefore,
518
+ bunLockBefore,
519
+ packageSnapshot,
520
+ );
521
+ } catch (rollbackErr) {
522
+ const message = err instanceof Error ? err.message : String(err);
523
+ const rollbackMessage = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
524
+ throw new Error(`${message}\nRollback failed: ${rollbackMessage}`);
525
+ }
526
+ throw err;
467
527
  } finally {
468
528
  await this.#cleanupSnapshot(packageSnapshot);
469
529
  }
@@ -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 };