@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.
- package/CHANGELOG.md +20 -0
- package/dist/cli.js +3105 -3105
- 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 +3 -3
- 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/internal-urls/docs-index.generated.txt +1 -1
- 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 +10 -4
- 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
|
*/
|
|
@@ -384,12 +384,10 @@ export declare class AgentSession {
|
|
|
384
384
|
get modelRegistry(): ModelRegistry;
|
|
385
385
|
get asyncJobManager(): AsyncJobManager | undefined;
|
|
386
386
|
getAgentId(): string | undefined;
|
|
387
|
-
/** Advance the tool-choice queue and return the next directive for the upcoming LLM call. */
|
|
388
|
-
nextToolChoice(): ToolChoice | undefined;
|
|
389
387
|
/**
|
|
390
388
|
* The per-turn tool-choice directive for the agent loop's `getToolChoice`. Priority:
|
|
391
389
|
* 1. a HARD forced choice from the queue (genuine forces: user-force, eager-todo, …) —
|
|
392
|
-
* consuming
|
|
390
|
+
* consuming (advances the queue generator);
|
|
393
391
|
* 2. else, when a non-forcing preview is pending, a {@link SoftToolRequirement} — a
|
|
394
392
|
* PEEK (advances/pops nothing), so the agent-loop injects the reminder once per head
|
|
395
393
|
* and escalates to a forced `resolve` only if the model declines. A compliant turn
|
|
@@ -399,6 +397,8 @@ export declare class AgentSession {
|
|
|
399
397
|
nextToolChoiceDirective(): ToolChoiceDirective | undefined;
|
|
400
398
|
/** Peek the head non-forcing pending preview invoker, for the `resolve` tool's dispatch. */
|
|
401
399
|
peekPendingInvoker(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
|
|
400
|
+
/** Clear stale non-forcing pending preview invokers after `resolve` proves none can run. */
|
|
401
|
+
clearPendingInvokers(): void;
|
|
402
402
|
/**
|
|
403
403
|
* Force the next model call to target a specific active tool, then terminate
|
|
404
404
|
* 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.5",
|
|
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.5",
|
|
52
|
+
"@oh-my-pi/omp-stats": "16.1.5",
|
|
53
|
+
"@oh-my-pi/pi-agent-core": "16.1.5",
|
|
54
|
+
"@oh-my-pi/pi-ai": "16.1.5",
|
|
55
|
+
"@oh-my-pi/pi-catalog": "16.1.5",
|
|
56
|
+
"@oh-my-pi/pi-mnemopi": "16.1.5",
|
|
57
|
+
"@oh-my-pi/pi-natives": "16.1.5",
|
|
58
|
+
"@oh-my-pi/pi-tui": "16.1.5",
|
|
59
|
+
"@oh-my-pi/pi-utils": "16.1.5",
|
|
60
|
+
"@oh-my-pi/pi-wire": "16.1.5",
|
|
61
|
+
"@oh-my-pi/snapcompact": "16.1.5",
|
|
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
|
}
|