@oh-my-pi/pi-coding-agent 16.1.2 → 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 +44 -1
- package/dist/cli.js +2990 -2991
- package/dist/types/config/model-resolver.d.ts +3 -3
- package/dist/types/mnemopi/embed-client.d.ts +70 -0
- package/dist/types/mnemopi/embed-protocol.d.ts +52 -0
- package/dist/types/mnemopi/embed-worker.d.ts +12 -0
- package/dist/types/mnemopi/state.d.ts +9 -1
- 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/agent-storage.d.ts +2 -0
- package/dist/types/session/auth-broker-config.d.ts +3 -2
- package/dist/types/session/history-storage.d.ts +1 -1
- package/dist/types/session/tool-choice-queue.d.ts +2 -0
- package/dist/types/tools/image-gen.d.ts +2 -2
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tui/hyperlink.d.ts +3 -2
- package/dist/types/utils/image-loading.d.ts +1 -1
- package/dist/types/utils/ipc.d.ts +22 -0
- package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
- package/package.json +12 -12
- package/src/cli/bench-cli.ts +33 -2
- package/src/cli/dry-balance-cli.ts +4 -2
- package/src/cli.ts +8 -0
- package/src/commands/token.ts +52 -33
- package/src/config/append-only-context-mode.ts +45 -0
- package/src/config/model-discovery.ts +3 -0
- package/src/config/model-registry.ts +21 -3
- package/src/config/model-resolver.ts +31 -8
- package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
- package/src/extensibility/plugins/manager.ts +82 -22
- package/src/lsp/client.ts +24 -0
- package/src/mnemopi/backend.ts +49 -3
- package/src/mnemopi/embed-client.ts +401 -0
- package/src/mnemopi/embed-protocol.ts +35 -0
- package/src/mnemopi/embed-worker.ts +113 -0
- package/src/mnemopi/state.ts +29 -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/custom-editor.ts +1 -1
- package/src/modes/components/model-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +64 -18
- package/src/modes/components/welcome.ts +1 -1
- package/src/modes/controllers/event-controller.ts +8 -0
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/theme/theme.ts +69 -0
- package/src/sdk.ts +37 -0
- package/src/session/agent-session.ts +13 -0
- package/src/session/agent-storage.ts +14 -0
- package/src/session/auth-broker-config.ts +2 -1
- package/src/session/history-storage.ts +13 -1
- package/src/session/tool-choice-queue.ts +6 -0
- package/src/stt/asr-client.ts +2 -7
- package/src/tiny/title-client.ts +2 -7
- package/src/tools/image-gen.ts +4 -8
- package/src/tools/index.ts +2 -0
- package/src/tools/render-utils.ts +4 -1
- package/src/tools/resolve.ts +1 -0
- package/src/tts/tts-client.ts +2 -7
- package/src/tui/hyperlink.ts +6 -3
- package/src/utils/image-loading.ts +12 -2
- package/src/utils/ipc.ts +38 -0
- package/src/web/search/providers/perplexity-auth.ts +133 -0
- package/src/web/search/providers/perplexity.ts +2 -125
package/src/commands/token.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
|
|
7
7
|
import chalk from "chalk";
|
|
8
8
|
import { isAuthenticated, ModelRegistry } from "../config/model-registry";
|
|
9
9
|
import { discoverAuthStorage } from "../sdk";
|
|
10
|
+
import { getAvailableAuthMethods } from "../web/search/providers/perplexity-auth";
|
|
10
11
|
|
|
11
12
|
export default class Token extends Command {
|
|
12
13
|
static description = "Get the API key or OAuth token for a provider";
|
|
@@ -41,49 +42,67 @@ export default class Token extends Command {
|
|
|
41
42
|
const provider = providerName.toLowerCase();
|
|
42
43
|
|
|
43
44
|
const authStorage = await discoverAuthStorage();
|
|
44
|
-
|
|
45
|
+
try {
|
|
46
|
+
const modelRegistry = new ModelRegistry(authStorage);
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
forceRefresh: flags["force-refresh"],
|
|
49
|
-
});
|
|
48
|
+
// Resolve the API key / token
|
|
49
|
+
let apiKey: string | undefined;
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
if (provider === "perplexity") {
|
|
52
|
+
const methods = await getAvailableAuthMethods(authStorage, undefined, {
|
|
53
|
+
forceRefresh: flags["force-refresh"],
|
|
54
|
+
});
|
|
55
|
+
const printable = methods.find(m => m.type === "oauth" || m.type === "api_key");
|
|
56
|
+
if (printable) {
|
|
57
|
+
apiKey = printable.type === "oauth" ? printable.access.accessToken : printable.apiKey;
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
60
|
+
|
|
61
|
+
if (!apiKey) {
|
|
62
|
+
apiKey = await modelRegistry.getApiKeyForProvider(provider, undefined, {
|
|
63
|
+
forceRefresh: flags["force-refresh"],
|
|
64
|
+
});
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
if (!isAuthenticated(apiKey)) {
|
|
68
|
+
// Find all active/configured providers
|
|
69
|
+
const activeProviders = new Set<string>();
|
|
70
|
+
for (const p of PROVIDER_REGISTRY) {
|
|
71
|
+
if (authStorage.hasAuth(p.id)) {
|
|
72
|
+
activeProviders.add(p.id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const all = authStorage.getAll();
|
|
76
|
+
for (const p in all) {
|
|
77
|
+
if (authStorage.hasAuth(p)) {
|
|
78
|
+
activeProviders.add(p);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const msg = `No active credential found for provider "${providerName}".`;
|
|
83
|
+
process.stderr.write(`${chalk.red(msg)}\n`);
|
|
84
|
+
if (activeProviders.size > 0) {
|
|
85
|
+
process.stderr.write(`Configured providers: ${Array.from(activeProviders).sort().join(", ")}\n`);
|
|
86
|
+
}
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
return;
|
|
70
89
|
}
|
|
71
|
-
process.exitCode = 1;
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
90
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
if (!flags.raw) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(apiKey);
|
|
94
|
+
if (parsed && typeof parsed === "object" && typeof parsed.token === "string") {
|
|
95
|
+
process.stdout.write(`${parsed.token}\n`);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Not a JSON string, print as-is
|
|
81
100
|
}
|
|
82
|
-
} catch {
|
|
83
|
-
// Not a JSON string, print as-is
|
|
84
101
|
}
|
|
85
|
-
}
|
|
86
102
|
|
|
87
|
-
|
|
103
|
+
process.stdout.write(`${apiKey}\n`);
|
|
104
|
+
} finally {
|
|
105
|
+
authStorage.close();
|
|
106
|
+
}
|
|
88
107
|
}
|
|
89
108
|
}
|
|
@@ -8,10 +8,55 @@ export interface AppendOnlyContextModel {
|
|
|
8
8
|
compatConfig?: object;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Local model servers (Ollama, LM Studio, llama.cpp, vLLM, sglang, …) all
|
|
13
|
+
* rely on llama.cpp-style prefix KV-cache reuse: identical leading tokens
|
|
14
|
+
* skip re-prefill on the next request. Append-only mode is the only way to
|
|
15
|
+
* guarantee byte-stable bytes across turns, since the live system prompt,
|
|
16
|
+
* tool catalogue, and message log all flow through fresh allocations every
|
|
17
|
+
* step (see `agent-loop.ts` `streamAssistantResponse` fallback path).
|
|
18
|
+
*/
|
|
19
|
+
const LOCAL_INFERENCE_PROVIDERS = new Set(["ollama", "ollama-cloud", "lm-studio", "llama.cpp"]);
|
|
20
|
+
|
|
21
|
+
/** True when `baseUrl` resolves to a loopback or RFC1918 host — covers
|
|
22
|
+
* llama.cpp/vLLM/sglang servers registered under a user-defined provider id
|
|
23
|
+
* via `models.yaml`. Built-in local provider ids (`ollama`, `lm-studio`,
|
|
24
|
+
* `llama.cpp`) are already handled by `LOCAL_INFERENCE_PROVIDERS`.
|
|
25
|
+
* Substring match on the parsed hostname only; ports, paths, and unparseable
|
|
26
|
+
* URLs return false.
|
|
27
|
+
*/
|
|
28
|
+
function hasLocalLoopbackBaseUrl(baseUrl: string | undefined): boolean {
|
|
29
|
+
if (!baseUrl) return false;
|
|
30
|
+
let hostname: string;
|
|
31
|
+
try {
|
|
32
|
+
hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
if (
|
|
37
|
+
hostname === "localhost" ||
|
|
38
|
+
hostname === "127.0.0.1" ||
|
|
39
|
+
hostname === "0.0.0.0" ||
|
|
40
|
+
hostname === "::1" ||
|
|
41
|
+
hostname === "[::1]"
|
|
42
|
+
) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
// RFC1918 private IPv4 ranges.
|
|
46
|
+
if (/^10\./.test(hostname)) return true;
|
|
47
|
+
if (/^192\.168\./.test(hostname)) return true;
|
|
48
|
+
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)) return true;
|
|
49
|
+
// Common ".local" mDNS hostnames used for home-LAN llama.cpp boxes.
|
|
50
|
+
if (hostname.endsWith(".local")) return true;
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
11
54
|
function shouldAutoEnableAppendOnlyContext(model: AppendOnlyContextModel | null | undefined): boolean {
|
|
12
55
|
if (!model) return false;
|
|
13
56
|
if (model.provider === "deepseek") return true;
|
|
57
|
+
if (LOCAL_INFERENCE_PROVIDERS.has(model.provider)) return true;
|
|
14
58
|
if (hostMatchesUrl(model.baseUrl, "xiaomi")) return true;
|
|
59
|
+
if (hasLocalLoopbackBaseUrl(model.baseUrl)) return true;
|
|
15
60
|
return !!model.compatConfig && "supportsStore" in model.compatConfig && model.compatConfig.supportsStore === true;
|
|
16
61
|
}
|
|
17
62
|
|
|
@@ -275,6 +275,7 @@ export async function discoverOllamaModels(
|
|
|
275
275
|
baseUrl: `${endpoint}/v1`,
|
|
276
276
|
reasoning: metadata?.reasoning ?? false,
|
|
277
277
|
input: metadata?.input ?? ["text"],
|
|
278
|
+
imageInputDecoder: "stb",
|
|
278
279
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
279
280
|
contextWindow: metadata?.contextWindow ?? 128000,
|
|
280
281
|
maxTokens: Math.min(metadata?.contextWindow ?? Number.POSITIVE_INFINITY, DISCOVERY_DEFAULT_MAX_TOKENS),
|
|
@@ -352,6 +353,7 @@ export async function discoverLlamaCppModels(
|
|
|
352
353
|
baseUrl,
|
|
353
354
|
reasoning: false,
|
|
354
355
|
input: serverMetadata?.input ?? ["text"],
|
|
356
|
+
imageInputDecoder: "stb",
|
|
355
357
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
356
358
|
contextWindow: serverMetadata?.contextWindow ?? 128000,
|
|
357
359
|
maxTokens: Math.min(
|
|
@@ -424,6 +426,7 @@ export async function discoverOpenAIModelsList(
|
|
|
424
426
|
baseUrl,
|
|
425
427
|
reasoning: false,
|
|
426
428
|
input: nativeMetadataForModel?.input ?? ["text"],
|
|
429
|
+
...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}),
|
|
427
430
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
428
431
|
contextWindow,
|
|
429
432
|
maxTokens: Math.min(contextWindow, discoveryDefaultMaxTokens(providerConfig.api)),
|
|
@@ -900,6 +900,7 @@ export class ModelRegistry {
|
|
|
900
900
|
...replacementModel,
|
|
901
901
|
contextWindow: replacementModel.contextWindow ?? existing.contextWindow,
|
|
902
902
|
maxTokens: replacementModel.maxTokens ?? existing.maxTokens,
|
|
903
|
+
omitMaxOutputTokens: replacementModel.omitMaxOutputTokens ?? existing.omitMaxOutputTokens,
|
|
903
904
|
...(supportsTools !== undefined ? { supportsTools } : {}),
|
|
904
905
|
};
|
|
905
906
|
});
|
|
@@ -1023,12 +1024,21 @@ export class ModelRegistry {
|
|
|
1023
1024
|
}
|
|
1024
1025
|
|
|
1025
1026
|
#normalizeDiscoverableModels(providerConfig: DiscoveryProviderConfig, models: Model<Api>[]): Model<Api>[] {
|
|
1027
|
+
const withDecoderMetadata =
|
|
1028
|
+
providerConfig.discovery.type === "ollama" ||
|
|
1029
|
+
providerConfig.discovery.type === "llama.cpp" ||
|
|
1030
|
+
providerConfig.discovery.type === "lm-studio"
|
|
1031
|
+
? models.map(model =>
|
|
1032
|
+
buildModel({ ...model, imageInputDecoder: "stb", compat: model.compatConfig } as ModelSpec<Api>),
|
|
1033
|
+
)
|
|
1034
|
+
: models;
|
|
1035
|
+
|
|
1026
1036
|
if (providerConfig.provider !== "ollama" || providerConfig.api !== "openai-responses") {
|
|
1027
|
-
return
|
|
1037
|
+
return withDecoderMetadata;
|
|
1028
1038
|
}
|
|
1029
1039
|
|
|
1030
1040
|
const contextLengthOverride = getOllamaContextLengthOverride();
|
|
1031
|
-
return
|
|
1041
|
+
return withDecoderMetadata.map(model => {
|
|
1032
1042
|
const normalized =
|
|
1033
1043
|
model.api === "openai-completions"
|
|
1034
1044
|
? buildModel({
|
|
@@ -1269,7 +1279,12 @@ export class ModelRegistry {
|
|
|
1269
1279
|
models: cached?.models.map(model => model.id) ?? [],
|
|
1270
1280
|
});
|
|
1271
1281
|
this.#lastDiscoveryWarnings.delete(providerConfig.provider);
|
|
1272
|
-
return cached
|
|
1282
|
+
return cached
|
|
1283
|
+
? this.#normalizeDiscoverableModels(
|
|
1284
|
+
providerConfig,
|
|
1285
|
+
cached.models.map(model => buildModel(model)),
|
|
1286
|
+
)
|
|
1287
|
+
: [];
|
|
1273
1288
|
}
|
|
1274
1289
|
}
|
|
1275
1290
|
|
|
@@ -1569,6 +1584,9 @@ export class ModelRegistry {
|
|
|
1569
1584
|
}
|
|
1570
1585
|
#applyHardcodedModelPolicies(models: Model<Api>[]): Model<Api>[] {
|
|
1571
1586
|
return models.map(model => {
|
|
1587
|
+
if (model.provider === "ollama-cloud" && model.omitMaxOutputTokens !== true) {
|
|
1588
|
+
model = applyModelOverride(model, { omitMaxOutputTokens: true });
|
|
1589
|
+
}
|
|
1572
1590
|
if (model.id !== "gpt-5.4" || model.provider === "github-copilot") {
|
|
1573
1591
|
return model;
|
|
1574
1592
|
}
|
|
@@ -556,6 +556,27 @@ function isAlias(id: string): boolean {
|
|
|
556
556
|
return !datePattern.test(id);
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
function includeSyntheticAllowedModels(available: Model<Api>[], allowedModels: Iterable<Model<Api>>): Model<Api>[] {
|
|
560
|
+
const allowedByKey = new Map<string, Model<Api>>();
|
|
561
|
+
for (const model of allowedModels) {
|
|
562
|
+
const key = formatModelString(model);
|
|
563
|
+
if (!allowedByKey.has(key)) {
|
|
564
|
+
allowedByKey.set(key, model);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (allowedByKey.size === 0) return [];
|
|
568
|
+
|
|
569
|
+
const result: Model<Api>[] = [];
|
|
570
|
+
for (const model of available) {
|
|
571
|
+
if (allowedByKey.delete(formatModelString(model))) {
|
|
572
|
+
result.push(model);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
result.push(...allowedByKey.values());
|
|
577
|
+
return result;
|
|
578
|
+
}
|
|
579
|
+
|
|
559
580
|
/**
|
|
560
581
|
* Find an exact explicit provider/model match.
|
|
561
582
|
* Bare model ids are handled separately so canonical ids can coalesce variants.
|
|
@@ -1335,9 +1356,9 @@ export async function resolveModelScope(
|
|
|
1335
1356
|
* the result to models matching those patterns.
|
|
1336
1357
|
*
|
|
1337
1358
|
* Returns the unfiltered available list when `enabledModels` is empty.
|
|
1338
|
-
* Returns an empty list when `enabledModels` is configured but no
|
|
1339
|
-
*
|
|
1340
|
-
*
|
|
1359
|
+
* Returns an empty list when `enabledModels` is configured but no model matches
|
|
1360
|
+
* any pattern — callers MUST treat this as "no usable model" rather than
|
|
1361
|
+
* falling back to the global default (see issue #1022).
|
|
1341
1362
|
*/
|
|
1342
1363
|
export async function resolveAllowedModels(
|
|
1343
1364
|
modelRegistry: Pick<ModelRegistry, "getAvailable" | "getCanonicalVariants">,
|
|
@@ -1353,8 +1374,10 @@ export async function resolveAllowedModels(
|
|
|
1353
1374
|
if (scoped.length === 0) {
|
|
1354
1375
|
return [];
|
|
1355
1376
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1377
|
+
return includeSyntheticAllowedModels(
|
|
1378
|
+
available,
|
|
1379
|
+
scoped.map(entry => entry.model),
|
|
1380
|
+
);
|
|
1358
1381
|
}
|
|
1359
1382
|
|
|
1360
1383
|
/**
|
|
@@ -1382,9 +1405,9 @@ export function filterAvailableModelsByEnabledPatterns(
|
|
|
1382
1405
|
if (patterns.length === 0) return available;
|
|
1383
1406
|
|
|
1384
1407
|
const context = buildPreferenceContext(available, undefined);
|
|
1385
|
-
const
|
|
1408
|
+
const allowedModels: Model<Api>[] = [];
|
|
1386
1409
|
const addAllowed = (model: Model<Api>) => {
|
|
1387
|
-
|
|
1410
|
+
allowedModels.push(model);
|
|
1388
1411
|
};
|
|
1389
1412
|
|
|
1390
1413
|
for (const pattern of patterns) {
|
|
@@ -1409,7 +1432,7 @@ export function filterAvailableModelsByEnabledPatterns(
|
|
|
1409
1432
|
}
|
|
1410
1433
|
}
|
|
1411
1434
|
|
|
1412
|
-
return
|
|
1435
|
+
return includeSyntheticAllowedModels(available, allowedModels);
|
|
1413
1436
|
}
|
|
1414
1437
|
|
|
1415
1438
|
export interface ResolveCliModelResult {
|
|
@@ -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
|
}
|
package/src/lsp/client.ts
CHANGED
|
@@ -482,6 +482,30 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
|
|
|
482
482
|
await sendResponse(client, message.id, null, message.method);
|
|
483
483
|
return;
|
|
484
484
|
}
|
|
485
|
+
if (message.method === "window/showMessageRequest") {
|
|
486
|
+
// Headless: no UI to surface the prompt. Spec says null = "no action selected".
|
|
487
|
+
await sendResponse(client, message.id, null, message.method);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (message.method === "window/showDocument") {
|
|
491
|
+
// Headless: nothing to display. Spec result is `{ success: boolean }`.
|
|
492
|
+
await sendResponse(client, message.id, { success: false }, message.method);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (
|
|
496
|
+
message.method === "workspace/semanticTokens/refresh" ||
|
|
497
|
+
message.method === "workspace/inlayHint/refresh" ||
|
|
498
|
+
message.method === "workspace/codeLens/refresh" ||
|
|
499
|
+
message.method === "workspace/codeAction/refresh" ||
|
|
500
|
+
message.method === "workspace/inlineValue/refresh" ||
|
|
501
|
+
message.method === "workspace/foldingRange/refresh" ||
|
|
502
|
+
message.method === "workspace/diagnostic/refresh"
|
|
503
|
+
) {
|
|
504
|
+
// Void acknowledgement per spec; servers that stall waiting for a reply
|
|
505
|
+
// (same failure mode as the dynamic-registration hang in #3029) move on.
|
|
506
|
+
await sendResponse(client, message.id, null, message.method);
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
485
509
|
await sendResponse(client, message.id, null, message.method, {
|
|
486
510
|
code: -32601,
|
|
487
511
|
message: `Method not found: ${message.method}`,
|
package/src/mnemopi/backend.ts
CHANGED
|
@@ -120,6 +120,14 @@ export const mnemopiBackend: MemoryBackend = {
|
|
|
120
120
|
const config = previous?.config ?? (session ? loadMnemopiConfig(session.settings, agentDir) : undefined);
|
|
121
121
|
if (!config) return;
|
|
122
122
|
await loadMnemopiCore();
|
|
123
|
+
// Close the cached default Mnemopi instance so its SQLite handle doesn't
|
|
124
|
+
// keep the DB files locked on Windows when removeDbFiles tries to delete.
|
|
125
|
+
// Use the core module (already awaited via loadMnemopiCore above):
|
|
126
|
+
// requireMnemopi() throws "module not loaded" when clear() runs before the
|
|
127
|
+
// fire-and-forget start() has awaited loadMnemopi() (autolearn disabled, or
|
|
128
|
+
// taskDepth > 0). resetMemoryForTests is re-exported identically from core.
|
|
129
|
+
requireMnemopiCore().resetMemoryForTests();
|
|
130
|
+
await Bun.sleep(0);
|
|
123
131
|
await removeDbFiles(getMnemopiScopedDbPaths(config));
|
|
124
132
|
},
|
|
125
133
|
|
|
@@ -557,10 +565,48 @@ export function getMnemopiDbDirForTests(session: AgentSession): string | undefin
|
|
|
557
565
|
return state ? path.dirname(state.config.dbPath) : undefined;
|
|
558
566
|
}
|
|
559
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Best-effort removal of a SQLite DB file and its WAL/SHM sidecars.
|
|
570
|
+
*
|
|
571
|
+
* Windows keeps `-wal`/`-shm` busy briefly after the DB handle closes, so a
|
|
572
|
+
* single `rm` races with EBUSY/EPERM. Retry a handful of times before giving
|
|
573
|
+
* up; `force: true` already makes "missing" a non-error.
|
|
574
|
+
*/
|
|
560
575
|
async function removeDbFiles(dbPaths: readonly string[]): Promise<void> {
|
|
561
576
|
for (const dbPath of dbPaths) {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
577
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
578
|
+
await removeWithRetries(`${dbPath}${suffix}`).catch(error => {
|
|
579
|
+
// `force: true` already makes ENOENT a non-error; anything else
|
|
580
|
+
// after the full retry window means the DB is genuinely locked and
|
|
581
|
+
// the user's "Memory cleared" message would be misleading. Log so
|
|
582
|
+
// the failure is diagnosable without blocking the clear flow.
|
|
583
|
+
const code = typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
584
|
+
if (code !== "ENOENT") {
|
|
585
|
+
logger.warn("Mnemopi: failed to remove DB file after retries", { path: `${dbPath}${suffix}`, code });
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const kRemoveRetries = 40;
|
|
593
|
+
const kRemoveRetryDelayMs = 25;
|
|
594
|
+
const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
|
|
595
|
+
|
|
596
|
+
async function removeWithRetries(target: string): Promise<void> {
|
|
597
|
+
for (let attempt = 0; ; attempt++) {
|
|
598
|
+
try {
|
|
599
|
+
await rm(target, { force: true });
|
|
600
|
+
return;
|
|
601
|
+
} catch (err) {
|
|
602
|
+
const retryable =
|
|
603
|
+
typeof err === "object" &&
|
|
604
|
+
err !== null &&
|
|
605
|
+
"code" in err &&
|
|
606
|
+
typeof err.code === "string" &&
|
|
607
|
+
kRetryableRemoveErrorCodes.has(err.code);
|
|
608
|
+
if (!retryable || attempt >= kRemoveRetries) throw err;
|
|
609
|
+
await Bun.sleep(kRemoveRetryDelayMs);
|
|
610
|
+
}
|
|
565
611
|
}
|
|
566
612
|
}
|