@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.
- package/CHANGELOG.md +14 -0
- package/dist/cli.js +2378 -2378
- package/dist/types/modes/components/cache-invalidation-marker.d.ts +23 -10
- package/dist/types/modes/components/status-line/component.d.ts +2 -3
- package/dist/types/sdk.d.ts +12 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/tool-choice-queue.d.ts +2 -0
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tui/hyperlink.d.ts +3 -2
- package/package.json +12 -12
- package/src/cli/bench-cli.ts +33 -2
- package/src/cli/dry-balance-cli.ts +4 -2
- package/src/extensibility/plugins/manager.ts +82 -22
- package/src/modes/components/cache-invalidation-marker.ts +31 -15
- package/src/modes/components/custom-editor.test.ts +4 -3
- package/src/modes/components/status-line/component.ts +64 -18
- package/src/sdk.ts +33 -0
- package/src/session/agent-session.ts +5 -0
- package/src/session/tool-choice-queue.ts +6 -0
- package/src/tools/index.ts +2 -0
- package/src/tools/resolve.ts +1 -0
- package/src/tui/hyperlink.ts +6 -3
|
@@ -6,20 +6,33 @@ export interface CacheInvalidation {
|
|
|
6
6
|
reprocessedTokens: number;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* Decide whether `current` turn lost
|
|
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
|
|
14
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
/**
|
package/dist/types/sdk.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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.
|
|
52
|
-
"@oh-my-pi/omp-stats": "16.1.
|
|
53
|
-
"@oh-my-pi/pi-agent-core": "16.1.
|
|
54
|
-
"@oh-my-pi/pi-ai": "16.1.
|
|
55
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
56
|
-
"@oh-my-pi/pi-mnemopi": "16.1.
|
|
57
|
-
"@oh-my-pi/pi-natives": "16.1.
|
|
58
|
-
"@oh-my-pi/pi-tui": "16.1.
|
|
59
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
60
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
61
|
-
"@oh-my-pi/snapcompact": "16.1.
|
|
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",
|
package/src/cli/bench-cli.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
//
|
|
355
|
-
const
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
26
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
*
|
|
525
|
-
*
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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 };
|