@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-vr9cckb4.md} +80 -0
  3. package/dist/cli.js +2993 -3001
  4. package/dist/docs-index.generated.txt +1 -1
  5. package/dist/{tool-views.generated-jdfmzwmn.js → tool-views.generated-dd2km5r2.js} +19 -19
  6. package/dist/types/advisor/advise-tool.d.ts +4 -2
  7. package/dist/types/cli/auth-broker-cli.d.ts +15 -0
  8. package/dist/types/cli/stats-cli.d.ts +1 -6
  9. package/dist/types/cli/update-cli.d.ts +8 -0
  10. package/dist/types/cli-commands.d.ts +10 -2
  11. package/dist/types/commands/stats.d.ts +4 -0
  12. package/dist/types/config/settings-schema.d.ts +28 -0
  13. package/dist/types/config/settings.d.ts +9 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +23 -4
  15. package/dist/types/extensibility/extensions/types.d.ts +58 -0
  16. package/dist/types/launch/presence.d.ts +4 -1
  17. package/dist/types/mcp/oauth-credentials.d.ts +23 -0
  18. package/dist/types/mcp/oauth-flow.d.ts +11 -0
  19. package/dist/types/mnemopi/backend.d.ts +12 -0
  20. package/dist/types/modes/components/tool-execution.d.ts +12 -0
  21. package/dist/types/modes/controllers/input-controller.d.ts +2 -0
  22. package/dist/types/modes/interactive-mode.d.ts +25 -3
  23. package/dist/types/modes/types.d.ts +10 -0
  24. package/dist/types/session/agent-session.d.ts +3 -0
  25. package/dist/types/session/prewalk.d.ts +4 -0
  26. package/dist/types/session/session-entries.d.ts +0 -1
  27. package/dist/types/session/session-manager.d.ts +12 -0
  28. package/dist/types/session/session-stats.d.ts +13 -1
  29. package/dist/types/session/skill-title-input.d.ts +13 -0
  30. package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
  31. package/dist/types/subprocess/worker-client.d.ts +7 -4
  32. package/dist/types/task/label.d.ts +2 -0
  33. package/dist/types/task/render.d.ts +2 -0
  34. package/dist/types/tiny/completion-prompt.d.ts +2 -0
  35. package/dist/types/tiny/title-client.d.ts +6 -4
  36. package/dist/types/tiny/title-protocol.d.ts +1 -0
  37. package/dist/types/tiny/worker.d.ts +27 -0
  38. package/dist/types/tools/bash.d.ts +1 -1
  39. package/dist/types/tools/file-write-fallback.d.ts +124 -0
  40. package/dist/types/tools/index.d.ts +1 -0
  41. package/dist/types/tools/path-utils.d.ts +23 -0
  42. package/dist/types/tools/read-format.d.ts +6 -0
  43. package/dist/types/tools/read-summary.d.ts +7 -1
  44. package/dist/types/utils/block-context.d.ts +14 -0
  45. package/dist/types/utils/fetch-timeout.d.ts +15 -0
  46. package/dist/types/utils/git.d.ts +25 -1
  47. package/dist/types/web/search/providers/tinyfish.d.ts +4 -0
  48. package/package.json +13 -13
  49. package/src/advisor/advise-tool.ts +5 -3
  50. package/src/cli/auth-broker-cli.ts +36 -1
  51. package/src/cli/profile-bootstrap.ts +2 -6
  52. package/src/cli/stats-cli.ts +6 -72
  53. package/src/cli/update-cli.ts +63 -11
  54. package/src/cli-commands.ts +61 -7
  55. package/src/commands/completions.ts +2 -1
  56. package/src/commands/stats.ts +7 -4
  57. package/src/commit/agentic/index.ts +15 -2
  58. package/src/commit/git/diff.ts +6 -2
  59. package/src/config/model-resolver.ts +52 -6
  60. package/src/config/models-config.ts +2 -2
  61. package/src/config/settings-schema.ts +33 -0
  62. package/src/config/settings.ts +159 -30
  63. package/src/discovery/helpers.ts +45 -2
  64. package/src/discovery/omp-plugins.ts +2 -1
  65. package/src/discovery/opencode.ts +56 -3
  66. package/src/edit/hashline/filesystem.ts +9 -3
  67. package/src/edit/modes/patch.ts +31 -5
  68. package/src/eval/js/process-entry.ts +4 -4
  69. package/src/export/html/tool-views.generated.js +19 -19
  70. package/src/extensibility/extensions/loader.ts +11 -0
  71. package/src/extensibility/extensions/runner.ts +118 -5
  72. package/src/extensibility/extensions/types.ts +60 -0
  73. package/src/extensibility/extensions/wrapper.ts +10 -1
  74. package/src/extensibility/plugins/legacy-pi-compat.ts +47 -0
  75. package/src/launch/client.ts +9 -4
  76. package/src/launch/presence.ts +19 -4
  77. package/src/lsp/defaults.json +1 -1
  78. package/src/lsp/writethrough.ts +20 -10
  79. package/src/mcp/manager.ts +41 -20
  80. package/src/mcp/oauth-credentials.ts +38 -0
  81. package/src/mcp/oauth-flow.ts +21 -0
  82. package/src/mcp/tool-bridge.ts +32 -16
  83. package/src/mnemopi/backend.ts +35 -3
  84. package/src/modes/components/model-hub.ts +37 -4
  85. package/src/modes/components/settings-selector.ts +17 -11
  86. package/src/modes/components/tool-execution.ts +97 -29
  87. package/src/modes/components/tree-selector.ts +7 -2
  88. package/src/modes/controllers/event-controller.ts +12 -2
  89. package/src/modes/controllers/input-controller.ts +64 -27
  90. package/src/modes/controllers/mcp-command-controller.ts +13 -4
  91. package/src/modes/interactive-mode.ts +79 -11
  92. package/src/modes/types.ts +11 -0
  93. package/src/prompts/system/memory-extraction-system.md +5 -22
  94. package/src/prompts/system/system-prompt.md +1 -1
  95. package/src/session/agent-session.ts +52 -6
  96. package/src/session/messages.ts +6 -0
  97. package/src/session/prewalk.ts +25 -7
  98. package/src/session/session-entries.ts +0 -1
  99. package/src/session/session-maintenance.ts +10 -1
  100. package/src/session/session-manager.ts +15 -0
  101. package/src/session/session-stats.ts +24 -3
  102. package/src/session/settings-stream-fn.ts +7 -0
  103. package/src/session/skill-title-input.ts +32 -0
  104. package/src/session/turn-recovery.ts +23 -18
  105. package/src/slash-commands/builtin-session.ts +1 -1
  106. package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
  107. package/src/subprocess/worker-client.ts +8 -5
  108. package/src/task/executor.ts +11 -0
  109. package/src/task/index.ts +2 -0
  110. package/src/task/label.ts +14 -1
  111. package/src/task/persisted-revive.ts +13 -0
  112. package/src/task/render.ts +1 -1
  113. package/src/task/structured-subagent.ts +5 -2
  114. package/src/tiny/completion-prompt.ts +16 -0
  115. package/src/tiny/title-client.ts +15 -6
  116. package/src/tiny/title-protocol.ts +8 -1
  117. package/src/tiny/worker.ts +21 -19
  118. package/src/tools/bash.ts +7 -1
  119. package/src/tools/file-write-fallback.ts +467 -0
  120. package/src/tools/index.ts +1 -0
  121. package/src/tools/path-utils.ts +79 -0
  122. package/src/tools/read-format.ts +16 -2
  123. package/src/tools/read-summary.ts +9 -4
  124. package/src/tools/read.ts +306 -72
  125. package/src/utils/block-context.ts +15 -1
  126. package/src/utils/fetch-timeout.ts +33 -0
  127. package/src/utils/git.ts +54 -11
  128. package/src/web/search/providers/browser-page.ts +21 -3
  129. package/src/web/search/providers/tinyfish.ts +26 -0
@@ -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 { kind: "loaded", settings: this.#migrateRawSettings(parsed as RawSettings) };
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 #loadExistingMainYaml(): Promise<RawSettings | null> {
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 = await this.#loadYamlIfPresentForStartup(configPath);
1223
- if (loaded) {
1224
- this.#configPath = configPath;
1225
- return loaded;
1226
- }
1227
- }
1228
- this.#configPath = path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
1229
- return null;
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 #loadProjectSettings(): Promise<RawSettings> {
1233
- this.#projectShellPathSource = undefined;
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")) this.#projectShellPathSource = item.path;
1350
+ if (Object.hasOwn(item.data, "shellPath")) shellPathSource = item.path;
1241
1351
  }
1242
1352
  }
1243
1353
  } catch {
1244
- this.#projectShellPathSource = undefined;
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 = await this.#loadYaml(projectConfigPath);
1250
- this.#projectFileSettings = structuredClone(nativeProject);
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 this.#migrateRawSettings(merged);
1367
+ return {
1368
+ settings: this.#migrateRawSettings(merged, quarantineInvalid),
1369
+ fileSettings: structuredClone(nativeProject),
1370
+ shellPathSource,
1371
+ };
1256
1372
  }
1257
1373
 
1258
- async #loadConfigOverlays(): Promise<RawSettings> {
1259
- this.#overlayShellPathSource = undefined;
1260
- let merged: RawSettings = {};
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
- merged = this.#deepMerge(merged, overlay);
1264
- if (Object.hasOwn(overlay, "shellPath")) this.#overlayShellPathSource = filePath;
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 merged;
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;
@@ -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 cacheKey = `${claudeConfigDir}:${ompRegistryPath}:${resolvedProjectPath ?? ""}:${activeClaudeProjectPath ?? ""}`;
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
- if (entry.scope === "local") {
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 = expandEnvVarsDeep(config) as OpenCodeMCPConfig;
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 fs.rm(absolutePath);
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
- await Bun.write(toAbsolute, content);
177
- await fs.rm(fromAbsolute);
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
  }
@@ -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 Bun.write(path, await serializeEditFileText(path, path, content));
152
+ await writeFileWithFallback(path, await serializeEditFileText(path, path, content));
127
153
  },
128
154
  async delete(path: string): Promise<void> {
129
- await fs.promises.unlink(path);
155
+ await deleteFileWithFallback(path);
130
156
  },
131
157
  async mkdir(path: string): Promise<void> {
132
- await fs.promises.mkdir(path, { recursive: true });
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).unlink();
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 fs.promises.mkdir(path, { recursive: true });
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 starts with its real cwd at the worker-host entry dir
23
- // (a `resolveWorkerSpawnCmd` requirement); mirror the session cwd so
24
- // cell code using relative paths or spawning children resolves against
25
- // the project instead of the install dir. Worker threads cannot pass
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,