@openchambery/web 2.1.8-beta.3 → 2.1.8-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/dist/assets/{AgentAvatar-DTgZob6V.js → AgentAvatar-qSjSZvIG.js} +1 -1
  2. package/dist/assets/{AssistantView-CzNcYiVL.js → AssistantView-DOr44dum.js} +3 -3
  3. package/dist/assets/{DiagramView-Cmdi3PAV.js → DiagramView-DwjEsYl2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-Dcu_ZfKa.js → FadeInOnReveal-CD5d1k_u.js} +1 -1
  5. package/dist/assets/{Icon-BKVaYkO0.js → Icon-QASb6r3F.js} +1 -1
  6. package/dist/assets/{ImageLoadingPlaceholder-CICdqe37.js → ImageLoadingPlaceholder-BuGiEI7w.js} +1 -1
  7. package/dist/assets/{LoadingImage-DC63uLZK.js → LoadingImage-FRySi1BM.js} +1 -1
  8. package/dist/assets/{MarkdownRenderer-CIapvMEi.js → MarkdownRenderer-BVTJzJKH.js} +2 -2
  9. package/dist/assets/{MarkdownRendererImpl-CuovZBls.js → MarkdownRendererImpl-DKRawvvq.js} +1 -1
  10. package/dist/assets/{MarkstreamRendererImpl-DtQlVw4f.js → MarkstreamRendererImpl-8IdRnZ2d.js} +1 -1
  11. package/dist/assets/{MobileDetailNavigation-BcQ1gCG1.js → MobileDetailNavigation-WqYrphqb.js} +1 -1
  12. package/dist/assets/{MobileShareBridge-CZbknYvz.js → MobileShareBridge-C6AvFHjG.js} +1 -1
  13. package/dist/assets/MobileTabPageHeader-BbDsAtKl.js +2 -0
  14. package/dist/assets/{MultiRunWindow-CsDXEsR_.js → MultiRunWindow-19VPj6Of.js} +1 -1
  15. package/dist/assets/{OnboardingScreen-B8R06QbP.js → OnboardingScreen-CHa767GT.js} +1 -1
  16. package/dist/assets/{OverlayScrollbar-BrYVChrM.js → OverlayScrollbar-C7Ls30Hy.js} +1 -1
  17. package/dist/assets/{SettingsView-bWUvVuP2.js → SettingsView-DruiCw4C.js} +10 -10
  18. package/dist/assets/{SettingsWindow-Cs1Z7BAg.js → SettingsWindow-BBfF03M7.js} +1 -1
  19. package/dist/assets/{TerminalView-Cfa5bIj1.js → TerminalView-BE1wI19_.js} +1 -1
  20. package/dist/assets/{ToolOutputDialog-ChVqyMGX.js → ToolOutputDialog-DOXDmUxQ.js} +1 -1
  21. package/dist/assets/{appThemeRegistry-CuS2weCQ.js → appThemeRegistry-D4TkQDbf.js} +1 -1
  22. package/dist/assets/{button-NnBwy1et.js → button-DOrwfkre.js} +1 -1
  23. package/dist/assets/{collapsible-Divs2HzS.js → collapsible-BJwPZMpo.js} +1 -1
  24. package/dist/assets/{diagram-Dw2fVTEr.js → diagram-BFevUcKu.js} +1 -1
  25. package/dist/assets/{dialog-thtcgEoi.js → dialog-FSXwOR2i.js} +1 -1
  26. package/dist/assets/{dropdown-menu.styles-DG86fojG.js → dropdown-menu.styles-CfTQwJHe.js} +1 -1
  27. package/dist/assets/{es-CyvQWK39.js → es-VbQQdCdi.js} +1 -1
  28. package/dist/assets/{fileMentionTouchSelection-D9YYbFDF.js → fileMentionTouchSelection-DuGw9C0F.js} +1 -1
  29. package/dist/assets/{fr-BgKUUIer.js → fr-BotRbS9t.js} +1 -1
  30. package/dist/assets/{gitApi-q2aVvcM1.js → gitApi-QxaZos8j.js} +1 -1
  31. package/dist/assets/{input-BFFtmAyl.js → input-Ctgb06ot.js} +1 -1
  32. package/dist/assets/{insertionBoundaries-B2M9QiEF.js → insertionBoundaries-BFtbjSBb.js} +1 -1
  33. package/dist/assets/{ja-vAsqZ3i2.js → ja-l_gq6Coh.js} +1 -1
  34. package/dist/assets/{ko-CjgHIGr_.js → ko-yhU5rfe5.js} +1 -1
  35. package/dist/assets/{main-B3grbXe8.js → main-BJckugyb.js} +2 -2
  36. package/dist/assets/{main-4h6m9pZ0.js → main-CSpFdmT4.js} +10 -10
  37. package/dist/assets/{markdownHeightCache-qy-s2Sd8.js → markdownHeightCache-B6drDnVO.js} +1 -1
  38. package/dist/assets/{mcpQueries-BD28dy9B.js → mcpQueries-WrVQC4xT.js} +1 -1
  39. package/dist/assets/miniChat-YVKGcjkV.js +2 -0
  40. package/dist/assets/mobile-DtAt6Tnq.js +2 -0
  41. package/dist/assets/{multirun-Dhq8Cx1W.js → multirun-lQfWeDYv.js} +1 -1
  42. package/dist/assets/{pl-CNRmHctC.js → pl-BkynZgOJ.js} +1 -1
  43. package/dist/assets/{projectMeta-2J4Ull4h.js → projectMeta-gv05m6Dm.js} +1 -1
  44. package/dist/assets/{pt-BR-B4NRHMT2.js → pt-BR-Dw_Dokez.js} +1 -1
  45. package/dist/assets/renderElectronMiniChatApp-BRhwzG7e.js +2 -0
  46. package/dist/assets/{renderMobileApp-Zl-L5gSE.js → renderMobileApp-FbnzqDL3.js} +6 -6
  47. package/dist/assets/{runtimeConfig-GAVzvQGc.js → runtimeConfig-Bm1EPWPq.js} +1 -1
  48. package/dist/assets/{runtimeEndpointReset-DdNzM2Ae.js → runtimeEndpointReset-C4gavnsn.js} +5 -5
  49. package/dist/assets/{scheduledTaskNavigation-DqwoOss-.js → scheduledTaskNavigation-DEMPeoot.js} +1 -1
  50. package/dist/assets/sessionChangeSummary-DkNmkmIq.js +1 -0
  51. package/dist/assets/{sessionLookup-BPCTTSdg.js → sessionLookup-DWJfnR75.js} +1 -1
  52. package/dist/assets/{sortable-tabs-strip-BLrK5Ln9.js → sortable-tabs-strip-BVnwfMAa.js} +1 -1
  53. package/dist/assets/speechSettingsQueries-CoNlugxH.js +4 -0
  54. package/dist/assets/{store-_hKtW1V3.js → store-COtDAfPx.js} +2 -2
  55. package/dist/assets/{textarea-CN-vZS5F.js → textarea-DJ52TDJj.js} +1 -1
  56. package/dist/assets/{tooltip-uzT2SsYJ.js → tooltip-JgW0lJaV.js} +1 -1
  57. package/dist/assets/{uk-CXna2F44.js → uk-DSl09YqU.js} +1 -1
  58. package/dist/assets/{useAppFontEffects-BlstuBSz.js → useAppFontEffects-DAeZcw5Z.js} +51 -53
  59. package/dist/assets/{useDesktopWindowControlsLayout-BhS1NmBt.js → useDesktopWindowControlsLayout-ia1w5b_c.js} +1 -1
  60. package/dist/assets/{useEffectiveDirectory-CiR2SqiL.js → useEffectiveDirectory-BWMFhdHo.js} +1 -1
  61. package/dist/assets/{useMobileNavigationStore-BRqifj9r.js → useMobileNavigationStore-D7wof6Tl.js} +6 -6
  62. package/dist/assets/{useWorkerHighlightedLines-Jea_E56b.js → useWorkerHighlightedLines-DdBSr8AB.js} +1 -1
  63. package/dist/assets/vendor-codemirror-autocomplete-7c2L_4dM.js +12 -0
  64. package/dist/assets/vendor-codemirror-commands-B8KrBQ9B.js +1 -0
  65. package/dist/assets/vendor-codemirror-lang-angular-DXOgWEVj.js +23 -0
  66. package/dist/assets/{vendor-codemirror-lang-cpp-C3UKgJaQ.js → vendor-codemirror-lang-cpp-Dh8C3xqP.js} +1 -1
  67. package/dist/assets/{vendor-codemirror-lang-go-Cv5NYdGm.js → vendor-codemirror-lang-go-BXAJ1_dh.js} +5 -5
  68. package/dist/assets/{vendor-codemirror-lang-java-D6hBTIgD.js → vendor-codemirror-lang-java-DAyCHRhk.js} +1 -1
  69. package/dist/assets/{vendor-codemirror-lang-jinja-ehGXhAov.js → vendor-codemirror-lang-jinja-0TICrMfO.js} +1 -1
  70. package/dist/assets/{vendor-codemirror-lang-json-tHviF6YN.js → vendor-codemirror-lang-json-mLulNbwB.js} +1 -1
  71. package/dist/assets/{vendor-codemirror-lang-less-B-yDfSDo.js → vendor-codemirror-lang-less-CNvptX3P.js} +1 -1
  72. package/dist/assets/{vendor-codemirror-lang-liquid-BdoLH4KN.js → vendor-codemirror-lang-liquid-8g5seytq.js} +1 -1
  73. package/dist/assets/vendor-codemirror-lang-markdown-CV1IO6Qs.js +6 -0
  74. package/dist/assets/{vendor-codemirror-lang-php-Ch8GW4Xz.js → vendor-codemirror-lang-php-xP7rf28_.js} +1 -1
  75. package/dist/assets/vendor-codemirror-lang-python-YM2WVLHF.js +9 -0
  76. package/dist/assets/{vendor-codemirror-lang-rust-BEUlElU6.js → vendor-codemirror-lang-rust-CtafR0gt.js} +1 -1
  77. package/dist/assets/vendor-codemirror-lang-sass-B4iav_Ps.js +1 -0
  78. package/dist/assets/{vendor-codemirror-lang-sql-BnZql454.js → vendor-codemirror-lang-sql-C6q09UUZ.js} +2 -2
  79. package/dist/assets/{vendor-codemirror-lang-vue-BCwQBvLR.js → vendor-codemirror-lang-vue-DIB78UsR.js} +1 -1
  80. package/dist/assets/{vendor-codemirror-lang-wast-mrtA2eML.js → vendor-codemirror-lang-wast-C_JVdOCF.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-lang-xml-ClAh-G2D.js → vendor-codemirror-lang-xml-DIuzY_d4.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-yaml-CjL8XEf2.js → vendor-codemirror-lang-yaml-BO0hIfQM.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-language-data-DGDYune4.js → vendor-codemirror-language-data-Ctd4WxgW.js} +2 -2
  84. package/dist/assets/vendor-codemirror-search-ZqtrPLjU.js +2 -0
  85. package/dist/assets/{vendor-replit-codemirror-vim-CEibx3EP.js → vendor-replit-codemirror-vim-DDj0dSVY.js} +6 -6
  86. package/dist/assets/{zh-CN-C0S7hK8T.js → zh-CN-NtO1bA00.js} +1 -1
  87. package/dist/assets/{zh-TW-CVG-Rqd-.js → zh-TW-CF0z_L0k.js} +1 -1
  88. package/dist/index.html +2 -2
  89. package/dist/mini-chat.html +2 -2
  90. package/dist/mobile.html +2 -2
  91. package/package.json +1 -1
  92. package/server/lib/config-sync/DOCUMENTATION.md +10 -5
  93. package/server/lib/config-sync/engine.js +1 -1
  94. package/server/lib/config-sync/magpie-auth.js +111 -0
  95. package/server/lib/config-sync/magpie-auth.test.js +79 -0
  96. package/server/lib/config-sync/magpie.js +76 -7
  97. package/server/lib/config-sync/magpie.test.js +102 -2
  98. package/server/lib/opencode/DOCUMENTATION.md +2 -0
  99. package/server/lib/opencode/config-entities-layering.test.js +2 -1
  100. package/server/lib/opencode/lifecycle.js +4 -0
  101. package/server/lib/opencode/lifecycle.test.js +7 -2
  102. package/server/lib/opencode/plugin-routes.test.js +1 -1
  103. package/server/lib/opencode/plugins.js +31 -21
  104. package/server/lib/opencode/plugins.test.js +55 -10
  105. package/dist/assets/MobileTabPageHeader-DjbyneLy.js +0 -2
  106. package/dist/assets/miniChat-ChSEmnwp.js +0 -2
  107. package/dist/assets/mobile-CrKlS4SH.js +0 -2
  108. package/dist/assets/renderElectronMiniChatApp-DsPg8kQs.js +0 -2
  109. package/dist/assets/sessionChangeSummary-CFlwf4sL.js +0 -2
  110. package/dist/assets/speechSettingsQueries-D4rx8jJk.js +0 -4
  111. package/dist/assets/vendor-codemirror-autocomplete-DHomsvW7.js +0 -12
  112. package/dist/assets/vendor-codemirror-commands-C9DCaJ0C.js +0 -1
  113. package/dist/assets/vendor-codemirror-lang-angular-BDXnzWgq.js +0 -23
  114. package/dist/assets/vendor-codemirror-lang-markdown-Bt-vLz9k.js +0 -6
  115. package/dist/assets/vendor-codemirror-lang-python-B4naLXMo.js +0 -9
  116. package/dist/assets/vendor-codemirror-lang-sass-ibtp2iUB.js +0 -1
  117. package/dist/assets/vendor-codemirror-search-D2SOgzph.js +0 -2
@@ -19,6 +19,7 @@ This module provides OpenCode server integration utilities for the web server ru
19
19
  from the authenticated client context (null for owner UI sessions), allowing
20
20
  connection shortcuts to exclude themselves without changing management lists.
21
21
  - `plugins.js`: config entries and plugin-directory files are inventory, not compatibility checks. They do not synthesize `compatibility` or `compatible` fields; Settings uses the OpenCode runtime inventory for actual load status and errors.
22
+ - Reads native V2 `plugins` (strings or `{ package, options }`) with legacy `plugin`/tuple fallback. A native array, including an empty one, wins within each document. Entry mutations write only `plugins`, convert retained legacy tuples, and remove the shadowed legacy key in the touched source file. Options, ordering, unrelated configuration and JSONC comments are preserved through the shared atomic config writer; malformed entries or write failures do not replace the original file. Web, Electron and mobile use this host implementation; VS Code mirrors the same persistence contract.
22
23
  - `session-directory-runtime.js`: Host-side physical directory identity for session index/list/detail/live projection. Uses a runtime-scoped, bounded 256-entry cache (60s success / 5s failed resolution), cleared when the upstream URL changes. Local directory lists use the upstream project identity and physical-directory filtering to include legacy symlink sessions without mixing worktrees. One request reads one page and preserves opaque cursors; consumers must follow continuations even after filtered-empty pages. Non-loopback upstreams keep their own path namespace.
23
24
  - `packages/web/server/lib/opencode/index.js`: public entrypoint (currently baseline placeholder).
24
25
  - `packages/web/server/lib/opencode/auth.js`: host-only authoritative provider credential API reads and deletion.
@@ -27,6 +28,7 @@ This module provides OpenCode server integration utilities for the web server ru
27
28
  - `packages/web/server/lib/opencode/cli-entry-runtime.js`: CLI entrypoint runtime that detects direct execution, forces `OPENCHAMBER_RUNTIME=web` unless already `ssh-remote` (so `dev:server` cannot inherit a leftover desktop env and host a relay, while SSH-managed remotes may), parses CLI options, and starts server bootstrap.
28
29
  - `packages/web/server/lib/opencode/routes.js`: OpenCode/provider settings and auth-related route registration.
29
30
  - `packages/web/server/lib/opencode/lifecycle.js`: OpenCode process lifecycle runtime (startup, restart, readiness, health monitoring). Managed child env inherits the user shell environment unchanged; experimental flags such as `OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS` follow the user's own environment and are never injected by OpenChamber.
31
+ - Before shared-service/private startup, config-sync's Magpie adapter repairs the discoverable command entry for an existing OpenChamber-managed Magpie CLI. No download occurs at startup. Errors remain explicit rather than starting OpenCode with an undiscoverable managed dependency. External services are not modified.
30
32
  - `packages/web/server/lib/opencode/v1-migration-gate.js`: pure V1→v2 migration admission gate (`GET /api/experimental/migration/v1` → whether transcript may be fetched).
31
33
  - `packages/web/server/lib/opencode/managed-capabilities-runtime.js`: managed-child scheduled-task resources, config injection, rotating bridge identity, and bridge authorization.
32
34
  - `packages/web/server/lib/opencode/env-runtime.js`: OpenCode CLI/binary resolution and shell environment runtime.
@@ -66,7 +66,8 @@ describe('Web/Electron entity physical provenance', () => {
66
66
  fs.writeFileSync(jsonc, '{ // retained\n"plugin":["higher-plugin"]}');
67
67
  const entry = plugins.listPluginEntries().find((item) => item.spec === 'lower-plugin');
68
68
  plugins.updatePluginEntry(entry.id, { spec: 'updated-plugin' });
69
- expect(readConfigFile(json).plugin).toEqual(['updated-plugin']);
69
+ expect(readConfigFile(json).plugins).toEqual(['updated-plugin']);
70
+ expect(readConfigFile(json).plugin).toBeUndefined();
70
71
  expect(readConfigFile(jsonc).plugin).toEqual(['higher-plugin']);
71
72
  });
72
73
  });
@@ -6,6 +6,7 @@ import { readOpenCode2BinaryVersion } from './ensure-cli.js';
6
6
  import { createRevokedRuntimeContract, evaluateRuntimeContract } from './runtime-contract.js';
7
7
  import { OPENCODE_V1_MIGRATION_PATH, fetchV1MigrationGate } from './v1-migration-gate.js';
8
8
  import { isProcessAlive } from './shared-service.js';
9
+ import { createMagpieSync } from '../config-sync/magpie.js';
9
10
 
10
11
  const parsePositiveInt = (value, fallback) => {
11
12
  const parsed = Number.parseInt(String(value ?? ''), 10);
@@ -71,6 +72,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
71
72
  buildAugmentedPath,
72
73
  buildManagedOpenCodePath,
73
74
  getManagedOpenCodeShellEnvSnapshot,
75
+ prepareMagpieInstallation = () => createMagpieSync().repairInstallation(),
74
76
  managedCapabilitiesRuntime = null,
75
77
  getActiveSessionCount = () => 0,
76
78
  // Official background service (shared-service.js). null keeps the private
@@ -691,6 +693,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
691
693
  * or the registration is missing/malformed; the caller decides rollback.
692
694
  */
693
695
  const connectSharedOpenCodeService = async ({ binaryPath } = {}) => {
696
+ await prepareMagpieInstallation();
694
697
  if (!binaryPath) {
695
698
  await applyOpencodeBinaryFromSettings({ strict: true });
696
699
  if (typeof ensurePinnedOpenCode2CliEnv === 'function') {
@@ -793,6 +796,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
793
796
  const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
794
797
 
795
798
  const startOpenCodeOnce = async () => {
799
+ await prepareMagpieInstallation();
796
800
  // New managed instance — drop any prior permit before spawn/probes.
797
801
  const generationAtStart = revokeRuntimeExecutionPermit('managed-start');
798
802
  const requestedPort = env.ENV_CONFIGURED_OPENCODE_PORT;
@@ -101,6 +101,7 @@ const createRuntime = (overrides = {}, stateRef = null) => {
101
101
  normalizeApiPrefix: vi.fn(() => ''),
102
102
  applyOpencodeBinaryFromSettings: vi.fn(async () => null),
103
103
  ensureOpencodeCliEnv: vi.fn(),
104
+ prepareMagpieInstallation: vi.fn(async () => {}),
104
105
  ensureLocalOpenCodeServerPassword: vi.fn(async () => 'password'),
105
106
  resolveManagedOpenCodeLaunchSpec: vi.fn((binary) => ({ binary, args: [], wrapperType: null })),
106
107
  setOpenCodePort: vi.fn((port) => {
@@ -195,14 +196,16 @@ describe('OpenCode lifecycle', () => {
195
196
  it('launches managed OpenCode with the managed PATH', async () => {
196
197
  delete process.env.OPENCODE_BINARY;
197
198
  const child = createMockChild();
199
+ const prepareMagpieInstallation = vi.fn(async () => {});
198
200
  spawnMock.mockImplementationOnce(() => {
201
+ expect(prepareMagpieInstallation).toHaveBeenCalledTimes(1);
199
202
  queueMicrotask(() => {
200
203
  child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n');
201
204
  });
202
205
  return child;
203
206
  });
204
207
 
205
- const runtime = createRuntime();
208
+ const runtime = createRuntime({ prepareMagpieInstallation });
206
209
  const server = await runtime.startOpenCode();
207
210
  const [binary, args, options] = spawnMock.mock.calls[0];
208
211
 
@@ -1100,13 +1103,15 @@ describe('shared official OpenCode service', () => {
1100
1103
  const sharedService = createSharedService();
1101
1104
  const adoptOpenCodeServerPassword = vi.fn();
1102
1105
  const stateRef = {};
1103
- const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword }, stateRef);
1106
+ const prepareMagpieInstallation = vi.fn(async () => {});
1107
+ const runtime = createRuntime({ env: UNCONFIGURED_ENV, sharedService, adoptOpenCodeServerPassword, prepareMagpieInstallation }, stateRef);
1104
1108
  const fetchMock = stubSharedFetch();
1105
1109
 
1106
1110
  await runtime.bootstrapOpenCodeAtStartup();
1107
1111
 
1108
1112
  expect(spawnMock).not.toHaveBeenCalled();
1109
1113
  expect(sharedService.start).toHaveBeenCalledTimes(1);
1114
+ expect(prepareMagpieInstallation.mock.invocationCallOrder[0]).toBeLessThan(sharedService.start.mock.invocationCallOrder[0]);
1110
1115
  const startInput = sharedService.start.mock.calls[0][0];
1111
1116
  expect(startInput.binary).toBe('/mock/opencode');
1112
1117
  expect(startInput.env.OPENCODE_SERVER_PASSWORD).toBeUndefined();
@@ -316,7 +316,7 @@ describe('opencode plugin routes', () => {
316
316
 
317
317
  const after = await request(app).get('/api/config/plugins').expect(200);
318
318
  expect(after.body.entries).toEqual([]);
319
- expect(readJson(userConfigPath).plugin).toBeUndefined();
319
+ expect(readJson(userConfigPath).plugins).toBeUndefined();
320
320
  expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled();
321
321
  });
322
322
 
@@ -79,6 +79,25 @@ function readPluginConfigLayers(workingDirectory) {
79
79
  return readConfigLayers(workingDirectory);
80
80
  }
81
81
 
82
+ function readPluginArray(config) {
83
+ if (Array.isArray(config.plugins)) return config.plugins;
84
+ return Array.isArray(config.plugin) ? config.plugin : [];
85
+ }
86
+
87
+ function writePluginArray(config, plugin, filePath) {
88
+ const next = { ...config };
89
+ delete next.plugin;
90
+ if (plugin.length > 0) {
91
+ next.plugins = plugin.map((raw) => {
92
+ const parsed = parsePluginRaw(raw);
93
+ return Array.isArray(raw) ? serializePluginEntry(parsed) : raw;
94
+ });
95
+ } else {
96
+ delete next.plugins;
97
+ }
98
+ writeConfig(next, filePath);
99
+ }
100
+
82
101
  function validateFileName(fileName) {
83
102
  if (typeof fileName !== 'string' || !fileName) {
84
103
  throw codedError('Plugin file name is required', 'INVALID_FILENAME');
@@ -125,9 +144,8 @@ function getPluginTarget(id, workingDirectory) {
125
144
  validateScope(scope);
126
145
  const layers = readPluginConfigLayers(workingDirectory);
127
146
  const source = configSources(layers).find((candidate) => candidate.scope === scope
128
- && Array.isArray(candidate.config?.plugin)
129
- && candidate.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec));
130
- const plugin = Array.isArray(source?.config?.plugin) ? source.config.plugin : [];
147
+ && readPluginArray(candidate.config).some((raw) => parsePluginRaw(raw).spec === spec));
148
+ const plugin = source ? [...readPluginArray(source.config)] : [];
131
149
  const index = plugin.findIndex((raw) => parsePluginRaw(raw).spec === spec);
132
150
  if (!source || index === -1) {
133
151
  return null;
@@ -181,13 +199,16 @@ function parsePluginRaw(raw) {
181
199
  if (Array.isArray(raw) && raw.length === 2 && isRecord(raw[1])) {
182
200
  return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
183
201
  }
184
- throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
202
+ if (isRecord(raw) && (raw.options === undefined || isRecord(raw.options))) {
203
+ return { spec: validatePluginSpec(raw.package), ...(raw.options === undefined ? {} : { options: { ...raw.options } }) };
204
+ }
205
+ throw codedError('Plugin spec must be a string or { package, options } object', 'INVALID_SPEC');
185
206
  }
186
207
 
187
208
  function serializePluginEntry(entry) {
188
209
  const spec = validatePluginSpec(entry?.spec);
189
210
  if (hasOptions(entry?.options)) {
190
- return [spec, { ...entry.options }];
211
+ return { package: spec, options: { ...entry.options } };
191
212
  }
192
213
  return spec;
193
214
  }
@@ -195,10 +216,7 @@ function serializePluginEntry(entry) {
195
216
  function listPluginEntries(workingDirectory) {
196
217
  const layers = readPluginConfigLayers(workingDirectory);
197
218
  return configSources(layers).flatMap((source) => {
198
- if (!Array.isArray(source.config?.plugin)) {
199
- return [];
200
- }
201
- return source.config.plugin.map((raw) => {
219
+ return readPluginArray(source.config).map((raw) => {
202
220
  const parsed = parsePluginRaw(raw);
203
221
  return {
204
222
  id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
@@ -225,8 +243,7 @@ function createPluginEntry(entry, workingDirectory) {
225
243
  const layers = readPluginConfigLayers(workingDirectory);
226
244
  const existing = configSources(layers).find((source) => (
227
245
  source.scope === scope
228
- && Array.isArray(source.config?.plugin)
229
- && source.config.plugin.some((raw) => parsePluginRaw(raw).spec === spec)
246
+ && readPluginArray(source.config).some((raw) => parsePluginRaw(raw).spec === spec)
230
247
  ));
231
248
  if (existing) {
232
249
  throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
@@ -242,11 +259,7 @@ function createPluginEntry(entry, workingDirectory) {
242
259
  config = getConfigForPath(layers, targetPath);
243
260
  }
244
261
 
245
- if (!Array.isArray(config.plugin)) {
246
- config.plugin = [];
247
- }
248
- config.plugin.push(serializePluginEntry({ spec, options: entry.options }));
249
- writeConfig(config, targetPath);
262
+ writePluginArray(config, [...readPluginArray(config), serializePluginEntry({ spec, options: entry.options })], targetPath);
250
263
  }
251
264
 
252
265
  function updatePluginEntry(id, updates, workingDirectory) {
@@ -258,7 +271,7 @@ function updatePluginEntry(id, updates, workingDirectory) {
258
271
  const nextSpec = updates?.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
259
272
  const nextOptions = updates?.options === undefined ? existing.options : updates.options;
260
273
  target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
261
- writeConfig(target.source.config, target.source.filePath);
274
+ writePluginArray(target.source.config, target.plugin, target.source.filePath);
262
275
  }
263
276
 
264
277
  function deletePluginEntry(id, workingDirectory) {
@@ -267,10 +280,7 @@ function deletePluginEntry(id, workingDirectory) {
267
280
  throw codedError('Plugin entry not found', 'NOT_FOUND');
268
281
  }
269
282
  target.plugin.splice(target.index, 1);
270
- if (target.plugin.length === 0) {
271
- delete target.source.config.plugin;
272
- }
273
- writeConfig(target.source.config, target.source.filePath);
283
+ writePluginArray(target.source.config, target.plugin, target.source.filePath);
274
284
  }
275
285
 
276
286
  function listPluginDirFiles(workingDirectory) {
@@ -52,6 +52,9 @@ describe('opencode plugins data layer', () => {
52
52
  expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
53
53
  expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
54
54
  expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
55
+ expect(plugins.parsePluginRaw({ package: 'foo', options: { a: 1 } })).toEqual({ spec: 'foo', options: { a: 1 } });
56
+ expect(plugins.parsePluginRaw({ package: 'foo' })).toEqual({ spec: 'foo' });
57
+ expect(() => plugins.parsePluginRaw({ package: 'foo', options: [] })).toThrow('Plugin spec');
55
58
  expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
56
59
  });
57
60
 
@@ -59,7 +62,7 @@ describe('opencode plugins data layer', () => {
59
62
  expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
60
63
  expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
61
64
  expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
62
- expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(['foo', { a: 1 }]);
65
+ expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual({ package: 'foo', options: { a: 1 } });
63
66
  });
64
67
 
65
68
  test('rejects invalid specs and file names', () => {
@@ -71,11 +74,11 @@ describe('opencode plugins data layer', () => {
71
74
  expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
72
75
  });
73
76
 
74
- test('creates string and tuple entries with duplicate rejection', () => {
77
+ test('creates V2 string and object entries with duplicate rejection', () => {
75
78
  plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
76
79
  plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
77
80
 
78
- expect(readJson(userConfigPath).plugin).toEqual(['plain-plugin', ['tuple-plugin', { apiKey: 'x' }]]);
81
+ expect(readJson(userConfigPath).plugins).toEqual(['plain-plugin', { package: 'tuple-plugin', options: { apiKey: 'x' } }]);
79
82
  expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
80
83
  expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
81
84
  });
@@ -84,8 +87,8 @@ describe('opencode plugins data layer', () => {
84
87
  plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
85
88
  plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
86
89
 
87
- expect(readJson(userConfigPath).plugin).toEqual(['user-plugin']);
88
- expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugin).toEqual(['project-plugin']);
90
+ expect(readJson(userConfigPath).plugins).toEqual(['user-plugin']);
91
+ expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugins).toEqual(['project-plugin']);
89
92
  });
90
93
 
91
94
  test('re-resolves custom config env between calls', () => {
@@ -100,20 +103,62 @@ describe('opencode plugins data layer', () => {
100
103
  plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
101
104
  plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
102
105
 
103
- expect(readJson(firstConfigPath).plugin).toEqual(['first-plugin']);
104
- expect(readJson(secondConfigPath).plugin).toEqual(['second-plugin']);
106
+ expect(readJson(firstConfigPath).plugins).toEqual(['first-plugin']);
107
+ expect(readJson(secondConfigPath).plugins).toEqual(['second-plugin']);
105
108
  expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
106
109
  expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
107
110
  });
108
111
 
109
- test('updates entries in place and transitions between string and tuple', () => {
112
+ test('migrates legacy entries in place and transitions between string and object', () => {
110
113
  writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
111
114
 
112
115
  plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
113
- expect(readJson(userConfigPath).plugin).toEqual(['first', 'second-new', 'third']);
116
+ expect(readJson(userConfigPath)).toEqual({ plugins: ['first', 'second-new', 'third'] });
114
117
 
115
118
  plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
116
- expect(readJson(userConfigPath).plugin).toEqual([['first-new', { b: 2 }], 'second-new', 'third']);
119
+ expect(readJson(userConfigPath).plugins).toEqual([{ package: 'first-new', options: { b: 2 } }, 'second-new', 'third']);
120
+ });
121
+
122
+ test('converts untouched legacy options on save and preserves unrelated JSONC', () => {
123
+ fs.writeFileSync(userConfigPath, '// retained comment\n' + JSON.stringify({ model: 'example/model', plugin: [['old', { nested: { enabled: false } }]] }));
124
+ plugins.createPluginEntry({ spec: 'new', scope: 'user' }, projectDir);
125
+ const text = fs.readFileSync(userConfigPath, 'utf8');
126
+ expect(text).toContain('// retained comment');
127
+ expect(plugins.listPluginEntries(projectDir).map(({ spec, options }) => ({ spec, options }))).toEqual([
128
+ { spec: 'old', options: { nested: { enabled: false } } }, { spec: 'new', options: undefined },
129
+ ]);
130
+ expect(text).toContain('"plugins"');
131
+ expect(text).not.toContain('"plugin"');
132
+ expect(text).toContain('example/model');
133
+ });
134
+
135
+ test('prefers native plugins over legacy entries including an empty native array', () => {
136
+ writeJson(userConfigPath, { plugin: ['shadowed'], plugins: [{ package: 'native', options: { enabled: false } }] });
137
+ expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['native']);
138
+ expect(() => plugins.createPluginEntry({ spec: 'native' }, projectDir)).toThrow('already exists');
139
+ plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:native'), projectDir);
140
+ expect(readJson(userConfigPath)).toEqual({});
141
+ writeJson(userConfigPath, { plugin: ['shadowed'], plugins: [] });
142
+ expect(plugins.listPluginEntries(projectDir)).toEqual([]);
143
+ plugins.createPluginEntry({ spec: 'new' }, projectDir);
144
+ expect(readJson(userConfigPath)).toEqual({ plugins: ['new'] });
145
+ });
146
+
147
+ test('malformed legacy data and failed atomic writes leave original config intact', () => {
148
+ writeJson(userConfigPath, { plugin: [['bad', null]], model: 'example/model' });
149
+ const malformed = fs.readFileSync(userConfigPath, 'utf8');
150
+ expect(() => plugins.createPluginEntry({ spec: 'new' }, projectDir)).toThrow('Plugin spec');
151
+ expect(fs.readFileSync(userConfigPath, 'utf8')).toBe(malformed);
152
+
153
+ writeJson(userConfigPath, { plugin: [['old', { enabled: false }]] });
154
+ const original = fs.readFileSync(userConfigPath, 'utf8');
155
+ const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { throw new Error('write failed'); });
156
+ try {
157
+ expect(() => plugins.createPluginEntry({ spec: 'new' }, projectDir)).toThrow('write failed');
158
+ expect(fs.readFileSync(userConfigPath, 'utf8')).toBe(original);
159
+ } finally {
160
+ rename.mockRestore();
161
+ }
117
162
  });
118
163
 
119
164
  test('deletes entries and prunes empty plugin key', () => {