@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.8
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 +80 -0
- package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
- package/dist/cli.js +2993 -3001
- package/dist/docs-index.generated.txt +1 -1
- package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
- package/dist/types/advisor/advise-tool.d.ts +4 -2
- package/dist/types/cli/auth-broker-cli.d.ts +15 -0
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/cli/update-cli.d.ts +8 -0
- package/dist/types/cli-commands.d.ts +10 -2
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +28 -0
- package/dist/types/config/settings.d.ts +9 -0
- package/dist/types/extensibility/extensions/runner.d.ts +23 -4
- package/dist/types/extensibility/extensions/types.d.ts +58 -0
- package/dist/types/launch/presence.d.ts +4 -1
- package/dist/types/mcp/oauth-credentials.d.ts +23 -0
- package/dist/types/mcp/oauth-flow.d.ts +11 -0
- package/dist/types/mnemopi/backend.d.ts +12 -0
- package/dist/types/modes/components/tool-execution.d.ts +12 -0
- package/dist/types/modes/controllers/input-controller.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +25 -3
- package/dist/types/modes/types.d.ts +10 -0
- package/dist/types/session/agent-session.d.ts +3 -0
- package/dist/types/session/prewalk.d.ts +4 -0
- package/dist/types/session/session-entries.d.ts +0 -1
- package/dist/types/session/session-manager.d.ts +12 -0
- package/dist/types/session/session-stats.d.ts +13 -1
- package/dist/types/session/skill-title-input.d.ts +13 -0
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/subprocess/worker-client.d.ts +7 -4
- package/dist/types/task/label.d.ts +2 -0
- package/dist/types/task/render.d.ts +2 -0
- package/dist/types/tiny/completion-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +6 -4
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tiny/worker.d.ts +27 -0
- package/dist/types/tools/bash.d.ts +1 -1
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/dist/types/tools/read-format.d.ts +6 -0
- package/dist/types/tools/read-summary.d.ts +7 -1
- package/dist/types/utils/block-context.d.ts +14 -0
- package/dist/types/utils/fetch-timeout.d.ts +15 -0
- package/dist/types/utils/git.d.ts +25 -1
- package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
- package/package.json +13 -13
- package/src/advisor/advise-tool.ts +5 -3
- package/src/cli/auth-broker-cli.ts +36 -1
- package/src/cli/profile-bootstrap.ts +2 -6
- package/src/cli/stats-cli.ts +6 -72
- package/src/cli/update-cli.ts +63 -11
- package/src/cli-commands.ts +61 -7
- package/src/commands/completions.ts +2 -1
- package/src/commands/stats.ts +7 -4
- package/src/commit/agentic/index.ts +15 -2
- package/src/commit/git/diff.ts +6 -2
- package/src/config/model-resolver.ts +52 -6
- package/src/config/models-config.ts +2 -2
- package/src/config/settings-schema.ts +33 -0
- package/src/config/settings.ts +159 -30
- package/src/discovery/helpers.ts +45 -2
- package/src/discovery/omp-plugins.ts +2 -1
- package/src/discovery/opencode.ts +56 -3
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/eval/js/process-entry.ts +4 -4
- package/src/export/html/tool-views.generated.js +19 -19
- package/src/extensibility/extensions/loader.ts +11 -0
- package/src/extensibility/extensions/runner.ts +118 -5
- package/src/extensibility/extensions/types.ts +60 -0
- package/src/extensibility/extensions/wrapper.ts +10 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
- package/src/launch/client.ts +9 -4
- package/src/launch/presence.ts +19 -4
- package/src/lsp/defaults.json +1 -1
- package/src/lsp/writethrough.ts +20 -10
- package/src/mcp/manager.ts +41 -20
- package/src/mcp/oauth-credentials.ts +38 -0
- package/src/mcp/oauth-flow.ts +21 -0
- package/src/mcp/tool-bridge.ts +32 -16
- package/src/mnemopi/backend.ts +35 -3
- package/src/modes/components/model-hub.ts +37 -4
- package/src/modes/components/settings-selector.ts +17 -11
- package/src/modes/components/tool-execution.ts +97 -29
- package/src/modes/components/tree-selector.ts +7 -2
- package/src/modes/controllers/event-controller.ts +12 -2
- package/src/modes/controllers/input-controller.ts +64 -27
- package/src/modes/controllers/mcp-command-controller.ts +13 -4
- package/src/modes/interactive-mode.ts +79 -11
- package/src/modes/types.ts +11 -0
- package/src/prompts/system/memory-extraction-system.md +5 -22
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/session/agent-session.ts +52 -6
- package/src/session/messages.ts +6 -0
- package/src/session/prewalk.ts +25 -7
- package/src/session/session-entries.ts +0 -1
- package/src/session/session-maintenance.ts +10 -1
- package/src/session/session-manager.ts +15 -0
- package/src/session/session-stats.ts +24 -3
- package/src/session/settings-stream-fn.ts +7 -0
- package/src/session/skill-title-input.ts +32 -0
- package/src/session/turn-recovery.ts +23 -18
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/subprocess/worker-client.ts +8 -5
- package/src/task/executor.ts +11 -0
- package/src/task/index.ts +2 -0
- package/src/task/label.ts +14 -1
- package/src/task/persisted-revive.ts +13 -0
- package/src/task/render.ts +1 -1
- package/src/task/structured-subagent.ts +5 -2
- package/src/tiny/completion-prompt.ts +16 -0
- package/src/tiny/title-client.ts +15 -6
- package/src/tiny/title-protocol.ts +8 -1
- package/src/tiny/worker.ts +21 -19
- package/src/tools/bash.ts +7 -1
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
- package/src/tools/read-format.ts +16 -2
- package/src/tools/read-summary.ts +9 -4
- package/src/tools/read.ts +306 -72
- package/src/utils/block-context.ts +15 -1
- package/src/utils/fetch-timeout.ts +33 -0
- package/src/utils/git.ts +54 -11
- package/src/web/search/providers/browser-page.ts +21 -3
- package/src/web/search/providers/tinyfish.ts +26 -0
package/src/config/settings.ts
CHANGED
|
@@ -71,6 +71,22 @@ type YamlLoadResult =
|
|
|
71
71
|
| { kind: "invalid"; error: unknown; backupPath?: string }
|
|
72
72
|
| { kind: "unreadable"; error: unknown };
|
|
73
73
|
|
|
74
|
+
type MainYamlReadResult = {
|
|
75
|
+
settings: RawSettings | null;
|
|
76
|
+
configPath: string | null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ProjectSettingsReadResult = {
|
|
80
|
+
settings: RawSettings;
|
|
81
|
+
fileSettings: RawSettings;
|
|
82
|
+
shellPathSource: string | undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
type ConfigOverlayReadResult = {
|
|
86
|
+
settings: RawSettings;
|
|
87
|
+
shellPathSource: string | undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
74
90
|
export interface SettingsOptions {
|
|
75
91
|
/** Current working directory for project settings discovery */
|
|
76
92
|
cwd?: string;
|
|
@@ -353,6 +369,8 @@ export class Settings {
|
|
|
353
369
|
#modifiedProjectModelRoles = new Set<string>();
|
|
354
370
|
/** Individual global model roles modified during this session (for partial save) */
|
|
355
371
|
#modifiedGlobalModelRoles = new Set<string>();
|
|
372
|
+
/** Changes whenever a live API mutates a persisted layer. */
|
|
373
|
+
#persistedMutationGeneration = 0;
|
|
356
374
|
/**
|
|
357
375
|
* Original process-wide model-role overrides captured before a project edit
|
|
358
376
|
* temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored
|
|
@@ -370,6 +388,8 @@ export class Settings {
|
|
|
370
388
|
#savePromise?: Promise<void>;
|
|
371
389
|
#projectSaveTimer?: NodeJS.Timeout;
|
|
372
390
|
#projectSavePromise?: Promise<void>;
|
|
391
|
+
/** Coalesces concurrent persisted-layer refreshes into one atomic reload. */
|
|
392
|
+
#reloadFromDiskPromise?: Promise<void>;
|
|
373
393
|
|
|
374
394
|
/** Whether to persist changes */
|
|
375
395
|
#persist: boolean;
|
|
@@ -503,6 +523,7 @@ export class Settings {
|
|
|
503
523
|
const prev = this.get(path);
|
|
504
524
|
const segments = path.split(".");
|
|
505
525
|
setByPath(this.#global, segments, value);
|
|
526
|
+
this.#persistedMutationGeneration++;
|
|
506
527
|
this.#modified.add(path);
|
|
507
528
|
this.#rebuildMerged();
|
|
508
529
|
const next = this.get(path);
|
|
@@ -624,6 +645,83 @@ export class Settings {
|
|
|
624
645
|
return cloned;
|
|
625
646
|
}
|
|
626
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Re-read the current global, project, and explicit overlay layers from disk
|
|
650
|
+
* without replacing this instance or discarding runtime overrides.
|
|
651
|
+
*
|
|
652
|
+
* All sources are loaded before any live layer is replaced, so readers never
|
|
653
|
+
* observe a partially refreshed configuration. Concurrent callers share the
|
|
654
|
+
* same reload.
|
|
655
|
+
*/
|
|
656
|
+
async reloadFromDisk(): Promise<void> {
|
|
657
|
+
if (!this.#persist) return;
|
|
658
|
+
if (this.#reloadFromDiskPromise) return this.#reloadFromDiskPromise;
|
|
659
|
+
|
|
660
|
+
const reload = this.#reloadPersistedLayers();
|
|
661
|
+
this.#reloadFromDiskPromise = reload;
|
|
662
|
+
try {
|
|
663
|
+
await reload;
|
|
664
|
+
} finally {
|
|
665
|
+
if (this.#reloadFromDiskPromise === reload) {
|
|
666
|
+
this.#reloadFromDiskPromise = undefined;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async #reloadPersistedLayers(): Promise<void> {
|
|
672
|
+
for (;;) {
|
|
673
|
+
await this.flush();
|
|
674
|
+
const mutationGeneration = this.#persistedMutationGeneration;
|
|
675
|
+
const previousSignaledValues = {
|
|
676
|
+
modelRoles: this.get("modelRoles"),
|
|
677
|
+
sessionAccent: this.get("statusLine.sessionAccent"),
|
|
678
|
+
};
|
|
679
|
+
const previousHookValues = new Map<SettingPath, unknown>();
|
|
680
|
+
for (const key of Object.keys(SETTING_HOOKS) as SettingPath[]) {
|
|
681
|
+
previousHookValues.set(key, this.get(key));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const [globalResult, projectResult, overlayResult] = await Promise.allSettled([
|
|
685
|
+
this.#readExistingMainYaml(false),
|
|
686
|
+
this.#readProjectSettings(false),
|
|
687
|
+
this.#readConfigOverlays(false),
|
|
688
|
+
]);
|
|
689
|
+
if (mutationGeneration !== this.#persistedMutationGeneration) continue;
|
|
690
|
+
if (globalResult.status === "rejected") throw globalResult.reason;
|
|
691
|
+
if (projectResult.status === "rejected") throw projectResult.reason;
|
|
692
|
+
if (overlayResult.status === "rejected") throw overlayResult.reason;
|
|
693
|
+
|
|
694
|
+
this.#configPath = globalResult.value.configPath;
|
|
695
|
+
this.#global = globalResult.value.settings ?? {};
|
|
696
|
+
this.#project = projectResult.value.settings;
|
|
697
|
+
this.#projectFileSettings = projectResult.value.fileSettings;
|
|
698
|
+
this.#projectShellPathSource = projectResult.value.shellPathSource;
|
|
699
|
+
this.#configOverlay = overlayResult.value.settings;
|
|
700
|
+
this.#overlayShellPathSource = overlayResult.value.shellPathSource;
|
|
701
|
+
this.#rebuildMerged();
|
|
702
|
+
|
|
703
|
+
const nextModelRoles = this.get("modelRoles");
|
|
704
|
+
if (!Bun.deepEquals(nextModelRoles, previousSignaledValues.modelRoles)) {
|
|
705
|
+
this.#fireEffectiveSettingChanged("modelRoles", nextModelRoles, previousSignaledValues.modelRoles);
|
|
706
|
+
}
|
|
707
|
+
const nextSessionAccent = this.get("statusLine.sessionAccent");
|
|
708
|
+
if (!Bun.deepEquals(nextSessionAccent, previousSignaledValues.sessionAccent)) {
|
|
709
|
+
this.#fireEffectiveSettingChanged(
|
|
710
|
+
"statusLine.sessionAccent",
|
|
711
|
+
nextSessionAccent,
|
|
712
|
+
previousSignaledValues.sessionAccent,
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
for (const [key, previous] of previousHookValues) {
|
|
716
|
+
const next = this.get(key);
|
|
717
|
+
if (!Bun.deepEquals(next, previous)) {
|
|
718
|
+
SETTING_HOOKS[key]?.(next, previous);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
627
725
|
/**
|
|
628
726
|
* Re-scope this instance to a new working directory *in place*: reload the
|
|
629
727
|
* project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve
|
|
@@ -870,6 +968,7 @@ export class Settings {
|
|
|
870
968
|
current[role] = modelId;
|
|
871
969
|
setByPath(this.#project, ["modelRoles"], current);
|
|
872
970
|
this.#modifiedProjectModelRoles.add(role);
|
|
971
|
+
this.#persistedMutationGeneration++;
|
|
873
972
|
this.#rebuildMerged();
|
|
874
973
|
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
|
|
875
974
|
this.#queueProjectSave();
|
|
@@ -903,6 +1002,7 @@ export class Settings {
|
|
|
903
1002
|
// clobbered by this process's stale in-memory snapshot.
|
|
904
1003
|
setByPath(this.#global, ["modelRoles"], current);
|
|
905
1004
|
this.#modifiedGlobalModelRoles.add(role);
|
|
1005
|
+
this.#persistedMutationGeneration++;
|
|
906
1006
|
this.#rebuildMerged();
|
|
907
1007
|
this.#queueSave();
|
|
908
1008
|
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
|
|
@@ -1091,7 +1191,7 @@ export class Settings {
|
|
|
1091
1191
|
return loaded ?? {};
|
|
1092
1192
|
}
|
|
1093
1193
|
|
|
1094
|
-
async #loadYamlIfPresent(filePath: string): Promise<YamlLoadResult> {
|
|
1194
|
+
async #loadYamlIfPresent(filePath: string, captureLegacyChangelogVersion = true): Promise<YamlLoadResult> {
|
|
1095
1195
|
let content: string;
|
|
1096
1196
|
try {
|
|
1097
1197
|
content = await fs.promises.readFile(filePath, "utf8");
|
|
@@ -1115,7 +1215,10 @@ export class Settings {
|
|
|
1115
1215
|
error: new Error("Settings YAML must contain a mapping at the document root"),
|
|
1116
1216
|
};
|
|
1117
1217
|
}
|
|
1118
|
-
return {
|
|
1218
|
+
return {
|
|
1219
|
+
kind: "loaded",
|
|
1220
|
+
settings: this.#migrateRawSettings(parsed as RawSettings, captureLegacyChangelogVersion),
|
|
1221
|
+
};
|
|
1119
1222
|
}
|
|
1120
1223
|
|
|
1121
1224
|
async #resolveYamlWritePath(filePath: string): Promise<string> {
|
|
@@ -1215,55 +1318,81 @@ export class Settings {
|
|
|
1215
1318
|
}
|
|
1216
1319
|
}
|
|
1217
1320
|
|
|
1218
|
-
async #
|
|
1219
|
-
if (!this.#configPath) return null;
|
|
1321
|
+
async #readExistingMainYaml(quarantineInvalid: boolean): Promise<MainYamlReadResult> {
|
|
1322
|
+
if (!this.#configPath) return { settings: null, configPath: null };
|
|
1220
1323
|
for (const filename of MAIN_CONFIG_FILENAMES) {
|
|
1221
1324
|
const configPath = path.join(this.#agentDir, filename);
|
|
1222
|
-
const loaded =
|
|
1223
|
-
|
|
1224
|
-
this.#configPath
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1325
|
+
const loaded = quarantineInvalid
|
|
1326
|
+
? await this.#loadYamlIfPresentForStartup(configPath)
|
|
1327
|
+
: this.#unwrapYamlLoadResult(configPath, await this.#loadYamlIfPresent(configPath, false));
|
|
1328
|
+
if (loaded) return { settings: loaded, configPath };
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
settings: null,
|
|
1332
|
+
configPath: path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]),
|
|
1333
|
+
};
|
|
1230
1334
|
}
|
|
1231
1335
|
|
|
1232
|
-
async #
|
|
1233
|
-
|
|
1336
|
+
async #loadExistingMainYaml(): Promise<RawSettings | null> {
|
|
1337
|
+
const result = await this.#readExistingMainYaml(true);
|
|
1338
|
+
this.#configPath = result.configPath;
|
|
1339
|
+
return result.settings;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
async #readProjectSettings(quarantineInvalid: boolean): Promise<ProjectSettingsReadResult> {
|
|
1343
|
+
let shellPathSource: string | undefined;
|
|
1234
1344
|
let merged: RawSettings = {};
|
|
1235
1345
|
try {
|
|
1236
1346
|
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
|
|
1237
1347
|
for (const item of result.items as SettingsCapabilityItem[]) {
|
|
1238
1348
|
if (item.level === "project") {
|
|
1239
1349
|
merged = this.#deepMerge(merged, item.data as RawSettings);
|
|
1240
|
-
if (Object.hasOwn(item.data, "shellPath"))
|
|
1350
|
+
if (Object.hasOwn(item.data, "shellPath")) shellPathSource = item.path;
|
|
1241
1351
|
}
|
|
1242
1352
|
}
|
|
1243
1353
|
} catch {
|
|
1244
|
-
|
|
1354
|
+
shellPathSource = undefined;
|
|
1245
1355
|
// Capability discovery is best-effort; the native project config below
|
|
1246
1356
|
// remains authoritative for its model-role layer and must not be hidden.
|
|
1247
1357
|
}
|
|
1248
1358
|
const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
|
|
1249
|
-
const nativeProject =
|
|
1250
|
-
|
|
1359
|
+
const nativeProject = quarantineInvalid
|
|
1360
|
+
? await this.#loadYaml(projectConfigPath)
|
|
1361
|
+
: (this.#unwrapYamlLoadResult(projectConfigPath, await this.#loadYamlIfPresent(projectConfigPath, false)) ??
|
|
1362
|
+
{});
|
|
1251
1363
|
const nativeModelRoles = getByPath(nativeProject, ["modelRoles"]);
|
|
1252
1364
|
if (nativeModelRoles !== undefined) {
|
|
1253
1365
|
merged = this.#deepMerge(merged, { modelRoles: nativeModelRoles });
|
|
1254
1366
|
}
|
|
1255
|
-
return
|
|
1367
|
+
return {
|
|
1368
|
+
settings: this.#migrateRawSettings(merged, quarantineInvalid),
|
|
1369
|
+
fileSettings: structuredClone(nativeProject),
|
|
1370
|
+
shellPathSource,
|
|
1371
|
+
};
|
|
1256
1372
|
}
|
|
1257
1373
|
|
|
1258
|
-
async #
|
|
1259
|
-
|
|
1260
|
-
|
|
1374
|
+
async #loadProjectSettings(): Promise<RawSettings> {
|
|
1375
|
+
const result = await this.#readProjectSettings(true);
|
|
1376
|
+
this.#projectFileSettings = result.fileSettings;
|
|
1377
|
+
this.#projectShellPathSource = result.shellPathSource;
|
|
1378
|
+
return result.settings;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async #readConfigOverlays(captureLegacyChangelogVersion = true): Promise<ConfigOverlayReadResult> {
|
|
1382
|
+
let shellPathSource: string | undefined;
|
|
1383
|
+
let settings: RawSettings = {};
|
|
1261
1384
|
for (const filePath of this.#configFiles) {
|
|
1262
|
-
const overlay = await this.#loadOverlayYaml(filePath);
|
|
1263
|
-
|
|
1264
|
-
if (Object.hasOwn(overlay, "shellPath"))
|
|
1385
|
+
const overlay = await this.#loadOverlayYaml(filePath, captureLegacyChangelogVersion);
|
|
1386
|
+
settings = this.#deepMerge(settings, overlay);
|
|
1387
|
+
if (Object.hasOwn(overlay, "shellPath")) shellPathSource = filePath;
|
|
1265
1388
|
}
|
|
1266
|
-
return
|
|
1389
|
+
return { settings, shellPathSource };
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
async #loadConfigOverlays(): Promise<RawSettings> {
|
|
1393
|
+
const result = await this.#readConfigOverlays();
|
|
1394
|
+
this.#overlayShellPathSource = result.shellPathSource;
|
|
1395
|
+
return result.settings;
|
|
1267
1396
|
}
|
|
1268
1397
|
|
|
1269
1398
|
/**
|
|
@@ -1271,7 +1400,7 @@ export class Settings {
|
|
|
1271
1400
|
* missing or malformed files are hard errors so a typo'd path cannot
|
|
1272
1401
|
* silently fall back to the persistent settings.
|
|
1273
1402
|
*/
|
|
1274
|
-
async #loadOverlayYaml(filePath: string): Promise<RawSettings> {
|
|
1403
|
+
async #loadOverlayYaml(filePath: string, captureLegacyChangelogVersion = true): Promise<RawSettings> {
|
|
1275
1404
|
let content: string;
|
|
1276
1405
|
try {
|
|
1277
1406
|
content = await Bun.file(filePath).text();
|
|
@@ -1292,7 +1421,7 @@ export class Settings {
|
|
|
1292
1421
|
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
1293
1422
|
throw new Error(`Config overlay must be a YAML mapping: ${filePath}`);
|
|
1294
1423
|
}
|
|
1295
|
-
return this.#migrateRawSettings(parsed as RawSettings);
|
|
1424
|
+
return this.#migrateRawSettings(parsed as RawSettings, captureLegacyChangelogVersion);
|
|
1296
1425
|
}
|
|
1297
1426
|
|
|
1298
1427
|
async #migrateFromLegacy(): Promise<void> {
|
|
@@ -1333,7 +1462,7 @@ export class Settings {
|
|
|
1333
1462
|
}
|
|
1334
1463
|
|
|
1335
1464
|
/** Apply schema migrations to raw settings */
|
|
1336
|
-
#migrateRawSettings(raw: RawSettings): RawSettings {
|
|
1465
|
+
#migrateRawSettings(raw: RawSettings, captureLegacyChangelogVersion = true): RawSettings {
|
|
1337
1466
|
// queueMode -> steeringMode
|
|
1338
1467
|
if ("queueMode" in raw && !("steeringMode" in raw)) {
|
|
1339
1468
|
raw.steeringMode = raw.queueMode;
|
|
@@ -1345,7 +1474,7 @@ export class Settings {
|
|
|
1345
1474
|
// longer dirty user-tracked configs. Capture for marker seeding (see
|
|
1346
1475
|
// #seedLastChangelogVersionMarker), then strip the key — the next
|
|
1347
1476
|
// config save drops it from disk.
|
|
1348
|
-
if (typeof raw.lastChangelogVersion === "string") {
|
|
1477
|
+
if (captureLegacyChangelogVersion && typeof raw.lastChangelogVersion === "string") {
|
|
1349
1478
|
this.#legacyLastChangelogVersion ??= raw.lastChangelogVersion;
|
|
1350
1479
|
}
|
|
1351
1480
|
delete raw.lastChangelogVersion;
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -898,6 +898,40 @@ async function canonicalClaudeProjectPath(projectPath: string): Promise<string |
|
|
|
898
898
|
}
|
|
899
899
|
}
|
|
900
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Claude Code `enabledPlugins` overrides, merged across the same settings
|
|
903
|
+
* layers Claude Code itself consults: `<claude-config>/settings.json`, then
|
|
904
|
+
* `<dir>/.claude/settings.json` and `<dir>/.claude/settings.local.json` for
|
|
905
|
+
* each project directory (later layers win, `.local` wins within a layer).
|
|
906
|
+
*
|
|
907
|
+
* Returns `pluginId -> boolean` for the ids the user toggled explicitly, and
|
|
908
|
+
* the list of settings files that contributed (for cache keying).
|
|
909
|
+
*/
|
|
910
|
+
async function readClaudeEnabledPlugins(
|
|
911
|
+
claudeConfigDir: string,
|
|
912
|
+
projectDirs: string[],
|
|
913
|
+
): Promise<{ enabled: Map<string, boolean>; sources: string[] }> {
|
|
914
|
+
const enabled = new Map<string, boolean>();
|
|
915
|
+
const sources: string[] = [];
|
|
916
|
+
const candidates = [path.join(claudeConfigDir, "settings.json")];
|
|
917
|
+
for (const dir of projectDirs) {
|
|
918
|
+
candidates.push(path.join(dir, ".claude", "settings.json"), path.join(dir, ".claude", "settings.local.json"));
|
|
919
|
+
}
|
|
920
|
+
for (const file of candidates) {
|
|
921
|
+
const content = await readFile(file);
|
|
922
|
+
if (!content) continue;
|
|
923
|
+
const data = tryParseJson<{ enabledPlugins?: unknown }>(content);
|
|
924
|
+
if (!data || typeof data !== "object") continue;
|
|
925
|
+
const map = data.enabledPlugins;
|
|
926
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) continue;
|
|
927
|
+
sources.push(file);
|
|
928
|
+
for (const [pluginId, value] of Object.entries(map as Record<string, unknown>)) {
|
|
929
|
+
if (typeof value === "boolean") enabled.set(pluginId, value);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return { enabled, sources };
|
|
933
|
+
}
|
|
934
|
+
|
|
901
935
|
const pluginRootsCache = new Map<string, { roots: ClaudePluginRoot[]; warnings: string[] }>();
|
|
902
936
|
|
|
903
937
|
const pluginCacheInvalidators = new Set<() => void>();
|
|
@@ -922,7 +956,10 @@ export async function listClaudePluginRoots(
|
|
|
922
956
|
const resolvedProjectPath = cwd ? await resolveActiveProjectRegistryPath(cwd) : null;
|
|
923
957
|
const projectRoot = resolvedProjectPath ? path.dirname(path.dirname(path.dirname(resolvedProjectPath))) : cwd;
|
|
924
958
|
const activeClaudeProjectPath = projectRoot ? await canonicalClaudeProjectPath(projectRoot) : null;
|
|
925
|
-
const
|
|
959
|
+
const canonicalCwd = cwd ? await canonicalClaudeProjectPath(cwd) : null;
|
|
960
|
+
const settingsDirs = [...new Set([activeClaudeProjectPath, canonicalCwd].filter((d): d is string => !!d))];
|
|
961
|
+
const enabledOverrides = await readClaudeEnabledPlugins(claudeConfigDir, settingsDirs);
|
|
962
|
+
const cacheKey = `${claudeConfigDir}:${ompRegistryPath}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}:${canonicalCwd ?? ""}:${enabledOverrides.sources.join("|")}`;
|
|
926
963
|
const cached = pluginRootsCache.get(cacheKey);
|
|
927
964
|
if (cached) return cached;
|
|
928
965
|
|
|
@@ -961,7 +998,13 @@ export async function listClaudePluginRoots(
|
|
|
961
998
|
continue;
|
|
962
999
|
}
|
|
963
1000
|
if (entry.enabled === false) continue;
|
|
964
|
-
|
|
1001
|
+
// Claude Code's own on/off switch: `enabledPlugins` in settings.json /
|
|
1002
|
+
// settings.local.json. `false` hides the plugin here even though it is
|
|
1003
|
+
// installed; `true` opts a local-scope install into this project even
|
|
1004
|
+
// when its recorded projectPath is a different directory.
|
|
1005
|
+
const override = enabledOverrides.enabled.get(pluginId);
|
|
1006
|
+
if (override === false) continue;
|
|
1007
|
+
if (entry.scope === "local" && override !== true) {
|
|
965
1008
|
if (!entry.projectPath || !activeClaudeProjectPath) continue;
|
|
966
1009
|
let entryProjectPath = canonicalClaudeProjectPaths.get(entry.projectPath);
|
|
967
1010
|
if (entryProjectPath === undefined) {
|
|
@@ -32,6 +32,7 @@ import { legacyProviderAllowed } from "./agent-plugin-format";
|
|
|
32
32
|
import {
|
|
33
33
|
buildRuleFromMarkdown,
|
|
34
34
|
createSourceMeta,
|
|
35
|
+
expandEnvVarsDeep,
|
|
35
36
|
loadFilesFromDir,
|
|
36
37
|
parseRequestIdFormat,
|
|
37
38
|
scanSkillsFromDir,
|
|
@@ -312,7 +313,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
312
313
|
logger.warn(`[omp-plugins] Invalid JSON in ${mcpPath}`);
|
|
313
314
|
continue;
|
|
314
315
|
}
|
|
315
|
-
const servers = parsed.mcpServers;
|
|
316
|
+
const servers = expandEnvVarsDeep(parsed.mcpServers);
|
|
316
317
|
if (!servers || typeof servers !== "object" || Array.isArray(servers)) continue;
|
|
317
318
|
|
|
318
319
|
for (const [serverName, serverCfg] of Object.entries(servers)) {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Priority: 55 (tool-specific provider)
|
|
17
17
|
*/
|
|
18
|
+
import * as os from "node:os";
|
|
18
19
|
import * as path from "node:path";
|
|
19
20
|
import { isRecord, logger, parseFrontmatter } from "@oh-my-pi/pi-utils";
|
|
20
21
|
import { JSONC } from "bun";
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
buildExtensionModuleItems,
|
|
34
35
|
createSourceMeta,
|
|
35
36
|
discoverExtensionModulePaths,
|
|
36
|
-
expandEnvVarsDeep,
|
|
37
37
|
getProjectPath,
|
|
38
38
|
getUserPath,
|
|
39
39
|
loadFilesFromDir,
|
|
@@ -63,7 +63,7 @@ async function loadJsonConfig(
|
|
|
63
63
|
|
|
64
64
|
let parsed: unknown;
|
|
65
65
|
try {
|
|
66
|
-
parsed = JSONC.parse(content);
|
|
66
|
+
parsed = JSONC.parse(await substituteConfigVars(content, configPath));
|
|
67
67
|
} catch {
|
|
68
68
|
onInvalid(configPath);
|
|
69
69
|
return null;
|
|
@@ -75,6 +75,59 @@ async function loadJsonConfig(
|
|
|
75
75
|
return parsed;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Apply OpenCode's config variable substitution to raw config text.
|
|
80
|
+
*
|
|
81
|
+
* OpenCode expands `{env:VAR}` (the env value, or an empty string when unset)
|
|
82
|
+
* and `{file:path}` (file contents, trimmed and JSON-escaped) at load time,
|
|
83
|
+
* before the JSON is parsed — see opencode `packages/opencode/src/config/variable.ts`.
|
|
84
|
+
* OMP loads the same config files, so it MUST honor the same syntax; the generic
|
|
85
|
+
* `${VAR}` expansion used elsewhere never matches, leaving a header like
|
|
86
|
+
* `Bearer {env:MCP_KEY}` to reach the MCP server verbatim and 401 (#8778).
|
|
87
|
+
*
|
|
88
|
+
* `{file:path}` resolves relative to the config file's directory, or from a `~`/
|
|
89
|
+
* absolute path. A token on a `//` comment line is left untouched, matching
|
|
90
|
+
* OpenCode. A missing file expands to an empty string (OpenCode's `missing:
|
|
91
|
+
* "empty"` mode) rather than aborting discovery.
|
|
92
|
+
*/
|
|
93
|
+
async function substituteConfigVars(text: string, configPath: string): Promise<string> {
|
|
94
|
+
const envExpanded = text.replace(/\{env:([^}]+)\}/g, (_, name: string) => Bun.env[name] ?? "");
|
|
95
|
+
|
|
96
|
+
const fileMatches = [...envExpanded.matchAll(/\{file:[^}]+\}/g)];
|
|
97
|
+
if (fileMatches.length === 0) return envExpanded;
|
|
98
|
+
|
|
99
|
+
const configDir = path.dirname(configPath);
|
|
100
|
+
let out = "";
|
|
101
|
+
let cursor = 0;
|
|
102
|
+
for (const match of fileMatches) {
|
|
103
|
+
const token = match[0];
|
|
104
|
+
const index = match.index ?? 0;
|
|
105
|
+
out += envExpanded.slice(cursor, index);
|
|
106
|
+
cursor = index + token.length;
|
|
107
|
+
|
|
108
|
+
// A `{file:...}` sitting on a JSONC comment line is not a real reference.
|
|
109
|
+
const lineStart = envExpanded.lastIndexOf("\n", index - 1) + 1;
|
|
110
|
+
if (envExpanded.slice(lineStart, index).trimStart().startsWith("//")) {
|
|
111
|
+
out += token;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let filePath = token.slice("{file:".length, -1);
|
|
116
|
+
if (filePath.startsWith("~/")) filePath = path.join(os.homedir(), filePath.slice(2));
|
|
117
|
+
const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath);
|
|
118
|
+
|
|
119
|
+
const fileContent = await readFile(resolved);
|
|
120
|
+
if (fileContent === null) {
|
|
121
|
+
logger.warn("OpenCode config references a missing file", { configPath, path: resolved });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
// JSON-escape so multi-line/quoted contents stay valid inside the string literal.
|
|
125
|
+
out += JSON.stringify(fileContent.trim()).slice(1, -1);
|
|
126
|
+
}
|
|
127
|
+
out += envExpanded.slice(cursor);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
78
131
|
/**
|
|
79
132
|
* OpenCode config sources in ascending effective precedence (lowest first):
|
|
80
133
|
* user `opencode.json` → user `opencode.jsonc` → project-root
|
|
@@ -215,7 +268,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
215
268
|
|
|
216
269
|
const items: MCPServer[] = [];
|
|
217
270
|
for (const [name, config] of mergedByName) {
|
|
218
|
-
const serverConfig =
|
|
271
|
+
const serverConfig = config as OpenCodeMCPConfig;
|
|
219
272
|
const source = sourceByName.get(name)!;
|
|
220
273
|
items.push(buildMCPServer(name, serverConfig, source));
|
|
221
274
|
}
|
|
@@ -25,6 +25,7 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
|
|
|
25
25
|
import type { ToolSession } from "../../tools";
|
|
26
26
|
import { routeWriteThroughBridge } from "../../tools/acp-bridge";
|
|
27
27
|
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
|
|
28
|
+
import { deleteFileWithFallback, writeFileWithFallback } from "../../tools/file-write-fallback";
|
|
28
29
|
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
|
|
29
30
|
import { isInternalUrlPath } from "../../tools/path-utils";
|
|
30
31
|
import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard";
|
|
@@ -153,7 +154,7 @@ export class HashlineFilesystem extends Filesystem {
|
|
|
153
154
|
enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
|
|
154
155
|
const absolutePath = this.resolveAbsolute(relativePath);
|
|
155
156
|
try {
|
|
156
|
-
await
|
|
157
|
+
await deleteFileWithFallback(absolutePath);
|
|
157
158
|
} catch (error) {
|
|
158
159
|
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
|
|
159
160
|
throw error;
|
|
@@ -173,8 +174,13 @@ export class HashlineFilesystem extends Filesystem {
|
|
|
173
174
|
const fromAbsolute = this.resolveAbsolute(fromRelative);
|
|
174
175
|
const toAbsolute = this.resolveAbsolute(toRelative);
|
|
175
176
|
if (content !== undefined) {
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
// The one `edit` write that does not pass through the writethrough, so it
|
|
178
|
+
// routes to the fallback seam directly. `patcher.ts` always supplies
|
|
179
|
+
// `content` for a hashline `MV`, making this the live branch. The source
|
|
180
|
+
// unlink is a separate primitive with its own seam, so a move out of the
|
|
181
|
+
// workspace and a move out of denied territory both complete.
|
|
182
|
+
await writeFileWithFallback(toAbsolute, content);
|
|
183
|
+
await deleteFileWithFallback(fromAbsolute);
|
|
178
184
|
} else {
|
|
179
185
|
await fs.rename(fromAbsolute, toAbsolute);
|
|
180
186
|
}
|
package/src/edit/modes/patch.ts
CHANGED
|
@@ -20,6 +20,12 @@ import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
|
|
|
20
20
|
import type { ToolSession } from "../../tools";
|
|
21
21
|
import { routeWriteThroughBridge } from "../../tools/acp-bridge";
|
|
22
22
|
import { assertEditableFile } from "../../tools/auto-generated-guard";
|
|
23
|
+
import {
|
|
24
|
+
deleteFileWithFallback,
|
|
25
|
+
hasFileWriteFallback,
|
|
26
|
+
isPermissionDeniedError,
|
|
27
|
+
writeFileWithFallback,
|
|
28
|
+
} from "../../tools/file-write-fallback";
|
|
23
29
|
import {
|
|
24
30
|
invalidateFsScanAfterDelete,
|
|
25
31
|
invalidateFsScanAfterRename,
|
|
@@ -111,6 +117,26 @@ export interface ApplyPatchOptions {
|
|
|
111
117
|
// Default File System
|
|
112
118
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Create a patch target's parent directory, tolerating a permission denial when a
|
|
122
|
+
* file-write fallback is registered.
|
|
123
|
+
*
|
|
124
|
+
* `apply_patch` mkdirs the parent before writing, so under a sandbox that denies
|
|
125
|
+
* the out-of-tree path this throws before the write — and therefore before
|
|
126
|
+
* {@link writeFileWithFallback} — is ever reached, leaving the fallback unable to
|
|
127
|
+
* broker a `create` or a rename-move into a new directory. Swallowing only a
|
|
128
|
+
* permission denial, and only with a handler installed, hands control to the write,
|
|
129
|
+
* which reports the denial through the seam. Without a handler the error propagates
|
|
130
|
+
* exactly as before.
|
|
131
|
+
*/
|
|
132
|
+
async function mkdirAllowingFallback(dir: string): Promise<void> {
|
|
133
|
+
try {
|
|
134
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (!hasFileWriteFallback() || !isPermissionDeniedError(error)) throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
114
140
|
/** Default filesystem implementation using Bun APIs */
|
|
115
141
|
export const defaultFileSystem: FileSystem = {
|
|
116
142
|
async exists(path: string): Promise<boolean> {
|
|
@@ -123,13 +149,13 @@ export const defaultFileSystem: FileSystem = {
|
|
|
123
149
|
return fs.promises.readFile(path);
|
|
124
150
|
},
|
|
125
151
|
async write(path: string, content: string): Promise<void> {
|
|
126
|
-
await
|
|
152
|
+
await writeFileWithFallback(path, await serializeEditFileText(path, path, content));
|
|
127
153
|
},
|
|
128
154
|
async delete(path: string): Promise<void> {
|
|
129
|
-
await
|
|
155
|
+
await deleteFileWithFallback(path);
|
|
130
156
|
},
|
|
131
157
|
async mkdir(path: string): Promise<void> {
|
|
132
|
-
await
|
|
158
|
+
await mkdirAllowingFallback(path);
|
|
133
159
|
},
|
|
134
160
|
};
|
|
135
161
|
|
|
@@ -1753,7 +1779,7 @@ class LspFileSystem implements FileSystem {
|
|
|
1753
1779
|
}
|
|
1754
1780
|
|
|
1755
1781
|
async delete(path: string): Promise<void> {
|
|
1756
|
-
await this.#getFile(path)
|
|
1782
|
+
await deleteFileWithFallback(path, this.#getFile(path));
|
|
1757
1783
|
if (this.session.enableLsp ?? true) {
|
|
1758
1784
|
await notifyWorkspaceWatchedFiles(
|
|
1759
1785
|
this.session.cwd,
|
|
@@ -1764,7 +1790,7 @@ class LspFileSystem implements FileSystem {
|
|
|
1764
1790
|
}
|
|
1765
1791
|
|
|
1766
1792
|
async mkdir(path: string): Promise<void> {
|
|
1767
|
-
await
|
|
1793
|
+
await mkdirAllowingFallback(path);
|
|
1768
1794
|
}
|
|
1769
1795
|
|
|
1770
1796
|
getDiagnostics(): FileDiagnosticsResult | undefined {
|
|
@@ -19,10 +19,10 @@ export function startJsEvalProcess(
|
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
mode: "isolated",
|
|
22
|
-
// The subprocess
|
|
23
|
-
//
|
|
24
|
-
// cell code using relative paths or spawning children
|
|
25
|
-
//
|
|
22
|
+
// The subprocess inherits the agent's cwd (or the package root under
|
|
23
|
+
// the `bun test` fallback of `resolveWorkerSpawnCmd`); mirror the
|
|
24
|
+
// session cwd so cell code using relative paths or spawning children
|
|
25
|
+
// resolves against the session's project. Worker threads cannot pass
|
|
26
26
|
// this — `process.chdir` is unavailable there.
|
|
27
27
|
chdir: cwd => process.chdir(cwd),
|
|
28
28
|
interceptUnhandledRejections,
|