@openchambery/web 2.1.7-beta.9 → 2.1.8-beta.10

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 (184) hide show
  1. package/dist/assets/{AgentAvatar-DxS7U-Cp.js → AgentAvatar-mchFM3n-.js} +1 -1
  2. package/dist/assets/AssistantView-pLVk20s1.js +3 -0
  3. package/dist/assets/{DiagramView-Bw2sCV3Y.js → DiagramView-DmaKTHol.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-kNFcAb-K.js → FadeInOnReveal-BXDM9joC.js} +1 -1
  5. package/dist/assets/{Icon-DxBvAKXH.js → Icon-BGp_ize2.js} +1 -1
  6. package/dist/assets/{ImageLoadingPlaceholder-DQvRSYkt.js → ImageLoadingPlaceholder-DmUxjyxq.js} +1 -1
  7. package/dist/assets/{LoadingImage-CaXJqavb.js → LoadingImage-DGjlKW_O.js} +1 -1
  8. package/dist/assets/MarkdownRenderer-VYYHEQHc.js +2 -0
  9. package/dist/assets/{MarkdownRendererImpl-EACzZK2L.js → MarkdownRendererImpl-BQPOlcOs.js} +2 -2
  10. package/dist/assets/{MarkstreamRendererImpl-yohfk508.js → MarkstreamRendererImpl-B9DtJ2K2.js} +1 -1
  11. package/dist/assets/{MobileDetailNavigation-ByVEkbBy.js → MobileDetailNavigation-9MnryETM.js} +1 -1
  12. package/dist/assets/{MobileShareBridge-DddCiczO.js → MobileShareBridge-e3WTrRks.js} +2 -2
  13. package/dist/assets/MobileTabPageHeader-Fh7QCZc7.js +2 -0
  14. package/dist/assets/{MultiRunWindow-CQWEGqds.js → MultiRunWindow-sfoabcvx.js} +1 -1
  15. package/dist/assets/{OnboardingScreen-BGZp5N1h.js → OnboardingScreen-DmJgU0Jc.js} +1 -1
  16. package/dist/assets/{OverlayScrollbar-OftHd9vR.js → OverlayScrollbar-BkJCkZp3.js} +1 -1
  17. package/dist/assets/{SettingsView-CDkq_RGv.js → SettingsView-DbNglFY1.js} +10 -9
  18. package/dist/assets/{SettingsWindow-CQhiLvNH.js → SettingsWindow-BGzCAKgh.js} +1 -1
  19. package/dist/assets/{TerminalView-CEiDqWoI.js → TerminalView-oT_BTx_W.js} +1 -1
  20. package/dist/assets/{ToolOutputDialog-jXsCdUsx.js → ToolOutputDialog-D85_cJ2B.js} +1 -1
  21. package/dist/assets/{appThemeRegistry-C90Vniux.js → appThemeRegistry-CGjHffdt.js} +1 -1
  22. package/dist/assets/{button-DfHNBl0U.js → button-BKjGCkH3.js} +1 -1
  23. package/dist/assets/{collapsible-BJ3u2c1m.js → collapsible-03FGLsF4.js} +1 -1
  24. package/dist/assets/{desktopHosts-BKeEWmL5.js → desktopHosts-BZ7iYE-l.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-FWm2fXr6.js → desktopRelayRestore-S9wEiY2U.js} +1 -1
  26. package/dist/assets/{diagram-KOjGEiKO.js → diagram-D4QHcaSQ.js} +1 -1
  27. package/dist/assets/{dialog-DBNBD_KB.js → dialog-nm_cQEUZ.js} +1 -1
  28. package/dist/assets/dropdown-menu.styles-WkR8IcF2.js +1 -0
  29. package/dist/assets/es-C9PRwAQm.js +15 -0
  30. package/dist/assets/{fileMentionTouchSelection-zOe2SiY_.js → fileMentionTouchSelection-DujPmFPX.js} +1 -1
  31. package/dist/assets/{fileQueries-Dhgypzyl.js → fileQueries-DZR-mK8h.js} +1 -1
  32. package/dist/assets/fr-2tfGTcme.js +13 -0
  33. package/dist/assets/{gitApi-pv3A0rDM.js → gitApi-CrqGdlbS.js} +1 -1
  34. package/dist/assets/{image-loading-DLh8WsH4.js → image-loading-CsxDGgek.js} +1 -1
  35. package/dist/assets/{input-CQ5f8Zqb.js → input-Buww8enN.js} +1 -1
  36. package/dist/assets/insertionBoundaries-o5RDEXaZ.js +1 -0
  37. package/dist/assets/ja-D7NHmhJr.js +15 -0
  38. package/dist/assets/ko-DeGmXmv6.js +15 -0
  39. package/dist/assets/main-6vgvFImW.js +169 -0
  40. package/dist/assets/main-Zix8q1Qb.js +2 -0
  41. package/dist/assets/{markdownHeightCache-BJ7s_IuQ.js → markdownHeightCache-B6wZ-UwD.js} +1 -1
  42. package/dist/assets/{mcpQueries-BlrZ9jet.js → mcpQueries-BVPwR92u.js} +1 -1
  43. package/dist/assets/miniChat-BYZJ4yQd.js +2 -0
  44. package/dist/assets/mobile-CvQVwDyf.js +2 -0
  45. package/dist/assets/{multirun-CZOOUr7O.js → multirun-BoFWk8S5.js} +1 -1
  46. package/dist/assets/{persistence-DUPZl7Oy.js → persistence-B4Ni3Gmv.js} +1 -1
  47. package/dist/assets/{pl-BE8MC0XQ.js → pl-CyWLY1AT.js} +3 -3
  48. package/dist/assets/{platform-z2erC6Bs.js → platform-BQPsr8Zl.js} +2 -2
  49. package/dist/assets/{projectMeta-DiTRU5Dp.js → projectMeta-B073H1YC.js} +1 -1
  50. package/dist/assets/pt-BR-D9VVxvts.js +15 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DfB14cCR.js → renderElectronMiniChatApp-Bu1k2_XW.js} +2 -2
  52. package/dist/assets/renderMobileApp-wEjrkNAR.js +28 -0
  53. package/dist/assets/{runtimeConfig-DHFwtNBi.js → runtimeConfig-DKN9Jyz-.js} +1 -1
  54. package/dist/assets/runtimeEndpointReset-Cqf421q-.js +149 -0
  55. package/dist/assets/scheduledTaskNavigation-C-HGgCQA.js +1 -0
  56. package/dist/assets/sessionChangeSummary-COHW-iNe.js +1 -0
  57. package/dist/assets/{sessionLookup-BSK3URnU.js → sessionLookup-ByvikQSd.js} +1 -1
  58. package/dist/assets/{sortable-tabs-strip-D655zJ3T.js → sortable-tabs-strip-cHZM0f0Z.js} +1 -1
  59. package/dist/assets/speechSettingsQueries-BJ7hAvj0.js +4 -0
  60. package/dist/assets/src-OjoeiMbd.css +2 -0
  61. package/dist/assets/store-Ds-7Em7C.js +15 -0
  62. package/dist/assets/{terminalApi-BnjU_lmF.js → terminalApi-kRMttlcP.js} +1 -1
  63. package/dist/assets/{textarea-HlFXg84f.js → textarea-BZHD_GND.js} +1 -1
  64. package/dist/assets/{tooltip-BQgoGUWx.js → tooltip-DCeWvAqN.js} +1 -1
  65. package/dist/assets/uk-Df44svOl.js +15 -0
  66. package/dist/assets/{url-DAyfGWPH.js → url-Bd61gNgl.js} +1 -1
  67. package/dist/assets/{useAppFontEffects-BQbKLVsD.js → useAppFontEffects-BqrJlSC2.js} +51 -53
  68. package/dist/assets/{useAssistantUIStore-Dt0l5AKe.js → useAssistantUIStore-Dlvb7P0q.js} +1 -1
  69. package/dist/assets/{useDesktopWindowControlsLayout-BIn2hI3V.js → useDesktopWindowControlsLayout-DgB-LNHC.js} +1 -1
  70. package/dist/assets/{useEffectiveDirectory-BycgG8Ov.js → useEffectiveDirectory-axifbbuL.js} +1 -1
  71. package/dist/assets/{useFontPreferences-B4Cgu3bS.js → useFontPreferences-lAw0Cbza.js} +1 -1
  72. package/dist/assets/useMobileNavigationStore-DnmY3KH3.js +417 -0
  73. package/dist/assets/useUIStore-CDMI_Yzu.js +1 -0
  74. package/dist/assets/{useWorkerHighlightedLines-DmI50_3w.js → useWorkerHighlightedLines-D2YpPANs.js} +1 -1
  75. package/dist/assets/vendor-codemirror-autocomplete-7c2L_4dM.js +12 -0
  76. package/dist/assets/vendor-codemirror-commands-B8KrBQ9B.js +1 -0
  77. package/dist/assets/vendor-codemirror-lang-angular-DXOgWEVj.js +23 -0
  78. package/dist/assets/{vendor-codemirror-lang-cpp-C3UKgJaQ.js → vendor-codemirror-lang-cpp-Dh8C3xqP.js} +1 -1
  79. package/dist/assets/{vendor-codemirror-lang-go-Cv5NYdGm.js → vendor-codemirror-lang-go-BXAJ1_dh.js} +5 -5
  80. package/dist/assets/{vendor-codemirror-lang-java-D6hBTIgD.js → vendor-codemirror-lang-java-DAyCHRhk.js} +1 -1
  81. package/dist/assets/{vendor-codemirror-lang-jinja-ehGXhAov.js → vendor-codemirror-lang-jinja-0TICrMfO.js} +1 -1
  82. package/dist/assets/{vendor-codemirror-lang-json-tHviF6YN.js → vendor-codemirror-lang-json-mLulNbwB.js} +1 -1
  83. package/dist/assets/{vendor-codemirror-lang-less-B-yDfSDo.js → vendor-codemirror-lang-less-CNvptX3P.js} +1 -1
  84. package/dist/assets/{vendor-codemirror-lang-liquid-BdoLH4KN.js → vendor-codemirror-lang-liquid-8g5seytq.js} +1 -1
  85. package/dist/assets/vendor-codemirror-lang-markdown-CV1IO6Qs.js +6 -0
  86. package/dist/assets/{vendor-codemirror-lang-php-Ch8GW4Xz.js → vendor-codemirror-lang-php-xP7rf28_.js} +1 -1
  87. package/dist/assets/vendor-codemirror-lang-python-YM2WVLHF.js +9 -0
  88. package/dist/assets/{vendor-codemirror-lang-rust-BEUlElU6.js → vendor-codemirror-lang-rust-CtafR0gt.js} +1 -1
  89. package/dist/assets/vendor-codemirror-lang-sass-B4iav_Ps.js +1 -0
  90. package/dist/assets/{vendor-codemirror-lang-sql-BnZql454.js → vendor-codemirror-lang-sql-C6q09UUZ.js} +2 -2
  91. package/dist/assets/{vendor-codemirror-lang-vue-BCwQBvLR.js → vendor-codemirror-lang-vue-DIB78UsR.js} +1 -1
  92. package/dist/assets/{vendor-codemirror-lang-wast-mrtA2eML.js → vendor-codemirror-lang-wast-C_JVdOCF.js} +1 -1
  93. package/dist/assets/{vendor-codemirror-lang-xml-ClAh-G2D.js → vendor-codemirror-lang-xml-DIuzY_d4.js} +1 -1
  94. package/dist/assets/{vendor-codemirror-lang-yaml-CjL8XEf2.js → vendor-codemirror-lang-yaml-BO0hIfQM.js} +1 -1
  95. package/dist/assets/{vendor-codemirror-language-data-DGDYune4.js → vendor-codemirror-language-data-Ctd4WxgW.js} +2 -2
  96. package/dist/assets/vendor-codemirror-search-ZqtrPLjU.js +2 -0
  97. package/dist/assets/{vendor-replit-codemirror-vim-CEibx3EP.js → vendor-replit-codemirror-vim-DDj0dSVY.js} +6 -6
  98. package/dist/assets/zh-CN-wowfzo60.js +15 -0
  99. package/dist/assets/zh-TW-DwojcgA_.js +15 -0
  100. package/dist/index.html +9 -9
  101. package/dist/mini-chat.html +9 -9
  102. package/dist/mobile.html +9 -9
  103. package/package.json +1 -1
  104. package/server/lib/assistants/DOCUMENTATION.md +40 -1
  105. package/server/lib/assistants/harness.js +21 -8
  106. package/server/lib/assistants/harness.test.js +22 -1
  107. package/server/lib/assistants/routes.js +2 -1
  108. package/server/lib/assistants/service.js +76 -13
  109. package/server/lib/assistants/service.test.js +53 -0
  110. package/server/lib/config-sync/DOCUMENTATION.md +38 -11
  111. package/server/lib/config-sync/config-sync.test.js +11 -11
  112. package/server/lib/config-sync/constants.js +0 -3
  113. package/server/lib/config-sync/contract.js +8 -8
  114. package/server/lib/config-sync/credentials.js +101 -0
  115. package/server/lib/config-sync/credentials.test.js +98 -0
  116. package/server/lib/config-sync/engine.js +17 -11
  117. package/server/lib/config-sync/index.js +0 -3
  118. package/server/lib/config-sync/local-backup.js +0 -13
  119. package/server/lib/config-sync/magpie-auth.js +111 -0
  120. package/server/lib/config-sync/magpie-auth.test.js +79 -0
  121. package/server/lib/config-sync/magpie.js +276 -0
  122. package/server/lib/config-sync/magpie.test.js +265 -0
  123. package/server/lib/config-sync/plan.js +32 -37
  124. package/server/lib/config-sync/receiver.js +28 -25
  125. package/server/lib/config-sync/routes.js +23 -2
  126. package/server/lib/config-sync/routes.test.js +64 -5
  127. package/server/lib/config-sync/scripts.js +2 -30
  128. package/server/lib/config-sync/selections.js +14 -10
  129. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  130. package/server/lib/opencode/DOCUMENTATION.md +4 -1
  131. package/server/lib/opencode/auth.d.ts +1 -0
  132. package/server/lib/opencode/auth.js +1 -1
  133. package/server/lib/opencode/config-entities-layering.test.js +2 -1
  134. package/server/lib/opencode/feature-routes-runtime.js +2 -0
  135. package/server/lib/opencode/lifecycle.js +4 -0
  136. package/server/lib/opencode/lifecycle.test.js +7 -2
  137. package/server/lib/opencode/plugin-routes.test.js +1 -1
  138. package/server/lib/opencode/plugins.js +31 -21
  139. package/server/lib/opencode/plugins.test.js +55 -10
  140. package/server/lib/scheduled-tasks/DOCUMENTATION.md +25 -0
  141. package/server/lib/scheduled-tasks/run-history-store.js +18 -5
  142. package/server/lib/scheduled-tasks/run-history-store.test.js +51 -0
  143. package/server/lib/scheduled-tasks/runtime.js +70 -12
  144. package/server/lib/scheduled-tasks/runtime.test.js +131 -0
  145. package/server/lib/session-folders/DOCUMENTATION.md +20 -0
  146. package/server/lib/session-folders/order-routes.js +69 -0
  147. package/server/lib/session-folders/routes.js +3 -0
  148. package/server/lib/session-folders/routes.test.js +49 -0
  149. package/server/lib/session-index/DOCUMENTATION.md +3 -0
  150. package/server/lib/session-index/sync-runtime.js +9 -4
  151. package/server/lib/session-index/sync-runtime.test.js +27 -0
  152. package/dist/assets/AssistantView-Bnaa8U5L.js +0 -3
  153. package/dist/assets/MarkdownRenderer-DpXaDH86.js +0 -2
  154. package/dist/assets/MobileTabPageHeader-CAtpMLFM.js +0 -2
  155. package/dist/assets/context-menu-DBNnM4a7.js +0 -1
  156. package/dist/assets/dropdown-menu.styles-Cbl6jSsR.js +0 -1
  157. package/dist/assets/es-2YvsazK2.js +0 -15
  158. package/dist/assets/fr-Cy0M48Gw.js +0 -13
  159. package/dist/assets/insertionBoundaries-CCxWV_ir.js +0 -1
  160. package/dist/assets/ja-1zLWWJ2R.js +0 -15
  161. package/dist/assets/ko-BLk_5Dhc.js +0 -15
  162. package/dist/assets/main-BbqWl6PU.js +0 -2
  163. package/dist/assets/main-KrnbhzQQ.js +0 -169
  164. package/dist/assets/miniChat-7ywVBnvf.js +0 -2
  165. package/dist/assets/mobile-_nwad-rW.js +0 -2
  166. package/dist/assets/pt-BR-ubhNymyH.js +0 -15
  167. package/dist/assets/renderMobileApp-fVXPQqNd.js +0 -28
  168. package/dist/assets/runtimeEndpointReset-DOHn9DSm.js +0 -149
  169. package/dist/assets/sessionChangeSummary-Y9NdeIoe.js +0 -2
  170. package/dist/assets/speechSettingsQueries-ojxskgsA.js +0 -4
  171. package/dist/assets/src-GOs_VnY-.css +0 -2
  172. package/dist/assets/store-n-H5RvRn.js +0 -15
  173. package/dist/assets/uk-BNyyigDo.js +0 -15
  174. package/dist/assets/useMobileNavigationStore-DZUur_4b.js +0 -417
  175. package/dist/assets/useUIStore-pl39iO82.js +0 -1
  176. package/dist/assets/vendor-codemirror-autocomplete-DHomsvW7.js +0 -12
  177. package/dist/assets/vendor-codemirror-commands-C9DCaJ0C.js +0 -1
  178. package/dist/assets/vendor-codemirror-lang-angular-BDXnzWgq.js +0 -23
  179. package/dist/assets/vendor-codemirror-lang-markdown-Bt-vLz9k.js +0 -6
  180. package/dist/assets/vendor-codemirror-lang-python-B4naLXMo.js +0 -9
  181. package/dist/assets/vendor-codemirror-lang-sass-ibtp2iUB.js +0 -1
  182. package/dist/assets/vendor-codemirror-search-D2SOgzph.js +0 -2
  183. package/dist/assets/zh-CN-CNWzMPgL.js +0 -15
  184. package/dist/assets/zh-TW-DrZ9S1yn.js +0 -15
@@ -27,7 +27,7 @@ afterEach(async () => {
27
27
  }
28
28
  });
29
29
 
30
- const createApp = async () => {
30
+ const createApp = async (credentials, magpie) => {
31
31
  const home = await makeTempDir();
32
32
  const configDir = path.join(home, '.config', 'opencode');
33
33
  await fsp.mkdir(configDir, { recursive: true });
@@ -36,6 +36,8 @@ const createApp = async () => {
36
36
 
37
37
  const receiver = createConfigSyncReceiver({
38
38
  homedir: () => home,
39
+ ...(credentials ? { credentials } : {}),
40
+ ...(magpie ? { magpie } : {}),
39
41
  });
40
42
 
41
43
  const app = express();
@@ -44,6 +46,63 @@ const createApp = async () => {
44
46
  };
45
47
 
46
48
  describe('config-sync HTTP routes', () => {
49
+ it('discovers Magpie without import and requires a selected transfer before finalizing', async () => {
50
+ const payload = Buffer.from('fixture-magpie-payload');
51
+ const received = [];
52
+ const { app } = await createApp(undefined, {
53
+ inventory: async () => ({ available: true, installed: false }),
54
+ download: async () => payload,
55
+ import: async (buffer) => { received.push(buffer); return { ok: true, magpieWarnings: ['sign_in_required'] }; },
56
+ });
57
+ const probe = await request(app).post('/api/openchamber/config-sync/probe').send({ plan: {} });
58
+ expect(probe.body.inventory.magpie).toEqual({ available: true, installed: false });
59
+ expect(received).toEqual([]);
60
+ const download = await request(app).get('/api/openchamber/config-sync/download/magpie').expect(200);
61
+ expect(download.headers['cache-control']).toBe('no-store');
62
+ expect(download.body).toEqual(payload);
63
+ await request(app).put('/api/openchamber/config-sync/magpie')
64
+ .set('Content-Type', 'application/octet-stream').send(payload).expect(200);
65
+ const plan = { files: [], directories: [], deletes: [], magpie: { bytes: 0 } };
66
+ await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'magpie-run' }).expect(200);
67
+ await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'magpie-run' }).expect(500);
68
+ await request(app).put('/api/openchamber/config-sync/put/magpie?syncRunId=magpie-run')
69
+ .set('Content-Type', 'application/octet-stream').send(payload).expect(200);
70
+ const receipt = await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'magpie-run' }).expect(200);
71
+ expect(receipt.body.magpieWarnings).toEqual(['sign_in_required']);
72
+ expect(received).toEqual([payload, payload]);
73
+ });
74
+
75
+ it('transfers credential bytes through HTTP without putting values in inventory or filesystem backups', async () => {
76
+ const payload = Buffer.from(JSON.stringify({ format: 'opencode-credentials-v1', entries: [{ fixture: 'private-value' }] }));
77
+ const received = [];
78
+ const { app, home } = await createApp({
79
+ inventory: async () => ({ bytes: payload.length }),
80
+ download: async () => payload,
81
+ import: async (buffer) => { received.push(buffer); return { ok: true }; },
82
+ });
83
+ const plan = { direction: 'push', files: [], directories: [], deletes: [], providerCredentials: { bytes: payload.length } };
84
+ const inventory = await request(app).get('/api/openchamber/config-sync/credentials/inventory');
85
+ expect(inventory.body).toEqual({ providerCredentials: { bytes: payload.length } });
86
+ const download = await request(app).get('/api/openchamber/config-sync/download/credentials');
87
+ expect(download.headers['cache-control']).toBe('no-store');
88
+ expect(download.body).toEqual(payload);
89
+ await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'credential-run' }).expect(200);
90
+ await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'credential-run' }).expect(500);
91
+ await request(app).put('/api/openchamber/config-sync/put/credentials?syncRunId=credential-run')
92
+ .set('Content-Type', 'application/octet-stream').send(download.body).expect(200);
93
+ await request(app).post('/api/openchamber/config-sync/finalize').send({ syncRunId: 'credential-run' }).expect(200);
94
+ await request(app).put('/api/openchamber/config-sync/credentials')
95
+ .set('Content-Type', 'application/octet-stream').send(payload).expect(200);
96
+ expect(received).toEqual([payload, payload]);
97
+ await expect(fsp.stat(path.join(home, '.openchamber.sync-backup-auth'))).rejects.toThrow();
98
+ await expect(fsp.stat(path.join(home, '.local', 'share', 'opencode', 'auth.json'))).rejects.toThrow();
99
+ await request(app).post('/api/openchamber/config-sync/prepare')
100
+ .send({ plan: { ...plan, providerCredentials: null }, syncRunId: 'unselected' }).expect(200);
101
+ await request(app).put('/api/openchamber/config-sync/put/credentials?syncRunId=unselected')
102
+ .set('Content-Type', 'application/octet-stream').send(payload).expect(500);
103
+ expect(received).toHaveLength(2);
104
+ });
105
+
47
106
  it.each(['http-config', 'http-agents', 'local-pack', 'ssh-config', 'ssh-agents'])(
48
107
  '%s archive excludes inventory-skipped files, including inside followed symlinks',
49
108
  async (transport) => {
@@ -91,7 +150,7 @@ describe('config-sync HTTP routes', () => {
91
150
  directories: [],
92
151
  deletes: ['config.json', 'opencode.json'],
93
152
  agentsRoot: null,
94
- authFile: null,
153
+ providerCredentials: null,
95
154
  totalBytes: 3,
96
155
  };
97
156
 
@@ -124,7 +183,7 @@ describe('config-sync HTTP routes', () => {
124
183
  directories: [],
125
184
  deletes: [],
126
185
  agentsRoot: null,
127
- authFile: null,
186
+ providerCredentials: null,
128
187
  totalBytes: 2,
129
188
  };
130
189
  const first = await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-a' });
@@ -143,7 +202,7 @@ describe('config-sync HTTP routes', () => {
143
202
  directories: [],
144
203
  deletes: [],
145
204
  agentsRoot: null,
146
- authFile: null,
205
+ providerCredentials: null,
147
206
  totalBytes: 0,
148
207
  };
149
208
  await request(app).post('/api/openchamber/config-sync/prepare').send({ plan, syncRunId: 'run-stream' });
@@ -151,7 +210,7 @@ describe('config-sync HTTP routes', () => {
151
210
  // Tiny invalid tar still proves chunk aggregation reached extract.
152
211
  await expect(receiver.put({
153
212
  syncRunId: 'run-stream',
154
- kind: 'auth',
213
+ kind: 'config',
155
214
  stream: Readable.from([Buffer.from('aa'), Buffer.from('bb')]),
156
215
  })).rejects.toThrow(/tar|failed|Local tar extract/i);
157
216
 
@@ -3,8 +3,6 @@ import path from 'node:path';
3
3
  import {
4
4
  OPENCODE_AGENTS_ROOT_PROBE_MARKER,
5
5
  OPENCODE_AGENTS_SYNC_BACKUP_DIR,
6
- OPENCODE_AUTH_FILE_PROBE_MARKER,
7
- OPENCODE_AUTH_SYNC_BACKUP_DIR,
8
6
  OPENCODE_CONFIG_SYNC_ALLOWLIST,
9
7
  OPENCODE_CONFIG_SYNC_BACKUP_DIR,
10
8
  OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
@@ -60,7 +58,7 @@ const generationalBackupSetupLines = ({ backupVar, runVar, quotedRunId, generati
60
58
  * {@link OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS} are pruned; prepare no longer
61
59
  * `rm -rf`s the entire backup root at start.
62
60
  *
63
- * @param {{ files: { path: string }[], directories: { path: string }[], deletes: string[], agentsRoot?: { fileCount: number, bytes: number } | null, authFile?: { bytes: number } | null }} plan
61
+ * @param {{ files: { path: string }[], directories: { path: string }[], deletes: string[], agentsRoot?: { fileCount: number, bytes: number } | null }} plan
64
62
  * @param {{ syncRunId: string, generations?: number }} options
65
63
  * @returns {string}
66
64
  */
@@ -134,19 +132,6 @@ export const buildRemoteSyncPrepareScript = (plan, options) => {
134
132
  lines.push('rm -rf -- "$HOME/.agents"');
135
133
  }
136
134
 
137
- if (plan?.authFile) {
138
- lines.push(`AUTH_BK="$HOME/${OPENCODE_AUTH_SYNC_BACKUP_DIR}"`);
139
- lines.push(...generationalBackupSetupLines({
140
- backupVar: 'AUTH_BK',
141
- runVar: 'AUTH_RUN_BK',
142
- quotedRunId,
143
- generations,
144
- }));
145
- lines.push('if [ -e "$HOME/.local/share/opencode/auth.json" ]; then');
146
- lines.push(' cp -a "$HOME/.local/share/opencode/auth.json" "$AUTH_RUN_BK/auth.json"');
147
- lines.push('fi');
148
- }
149
-
150
135
  lines.push("printf 'SYNC_READY\\n'");
151
136
  return lines.join('\n');
152
137
  };
@@ -164,9 +149,6 @@ export const buildRemoteSyncProbeScript = (uniqueProbePaths) => {
164
149
  lines.push(
165
150
  `[ -e "$HOME/.agents" ] && printf '%s\\n' ${shellQuote(OPENCODE_AGENTS_ROOT_PROBE_MARKER)}`,
166
151
  );
167
- lines.push(
168
- `[ -e "$HOME/.local/share/opencode/auth.json" ] && printf '%s\\n' ${shellQuote(OPENCODE_AUTH_FILE_PROBE_MARKER)}`,
169
- );
170
152
  lines.push('exit 0');
171
153
  return lines.join('\n');
172
154
  };
@@ -228,14 +210,9 @@ export const buildRemoteSyncInventoryScript = () => {
228
210
  lines.push(" ABYTES=\"$(find \"$HOME/.agents\" -type f ! -path '*/node_modules/*' ! -path '*/.venv-asc/*' ! -name '*.backup' -exec wc -c {} + 2>/dev/null | awk 'END{print $1+0}')\"");
229
211
  lines.push(' AGENTS="{\\"fileCount\\":$ACOUNT,\\"bytes\\":$ABYTES}"');
230
212
  lines.push('fi');
231
- lines.push('AUTH="null"');
232
- lines.push('if [ -f "$HOME/.local/share/opencode/auth.json" ]; then');
233
- lines.push(' AUBYTES="$(wc -c < "$HOME/.local/share/opencode/auth.json" | tr -d \' \')"');
234
- lines.push(' AUTH="{\\"bytes\\":$AUBYTES}"');
235
- lines.push('fi');
236
213
  lines.push('FILES="${FILES%,}"');
237
214
  lines.push('DIRS="${DIRS%,}"');
238
- lines.push('printf \'SYNC_INVENTORY={"files":[%s],"directories":[%s],"agentsRoot":%s,"authFile":%s}\\n\' "$FILES" "$DIRS" "$AGENTS" "$AUTH"');
215
+ lines.push('printf \'SYNC_INVENTORY={"files":[%s],"directories":[%s],"agentsRoot":%s}\\n\' "$FILES" "$DIRS" "$AGENTS"');
239
216
  lines.push('exit 0');
240
217
  return lines.join('\n');
241
218
  };
@@ -265,9 +242,4 @@ export const buildRemoteAgentsTarScript = () => [
265
242
  `cd "$HOME" && tar ${OPENCODE_CONFIG_SYNC_TAR_EXCLUDES.map(shellQuote).join(' ')} -h -czf - .agents`,
266
243
  ].join('\n');
267
244
 
268
- export const buildRemoteAuthTarScript = () => [
269
- 'set -e',
270
- 'cd "$HOME/.local/share/opencode" && tar -h -czf - auth.json',
271
- ].join('\n');
272
-
273
245
  export { shellQuote };
@@ -6,13 +6,14 @@ import { OPENCODE_CONFIG_SYNC_ALLOWLIST } from './constants.js';
6
6
  * singleFiles: boolean[],
7
7
  * directories: boolean[],
8
8
  * agentsRoot: boolean,
9
- * authFile: boolean,
9
+ * providerCredentials: boolean,
10
+ * magpie?: boolean,
10
11
  * }} SyncSelections
11
12
  */
12
13
 
13
14
  /**
14
- * Default: everything selected except authFile (credentials stay opt-in).
15
- * @param {{ includeAuthFile?: boolean }} [options]
15
+ * Default: everything selected except providerCredentials (credentials stay opt-in).
16
+ * @param {{ includeProviderCredentials?: boolean }} [options]
16
17
  * @returns {SyncSelections}
17
18
  */
18
19
  export const buildDefaultSyncSelections = (options = {}) => ({
@@ -20,13 +21,14 @@ export const buildDefaultSyncSelections = (options = {}) => ({
20
21
  singleFiles: OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles.map(() => true),
21
22
  directories: OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.map(() => true),
22
23
  agentsRoot: true,
23
- authFile: options.includeAuthFile === true,
24
+ providerCredentials: options.includeProviderCredentials === true,
25
+ magpie: false,
24
26
  });
25
27
 
26
28
  /**
27
29
  * Normalize a partial selections object against the allowlist shape.
28
30
  * @param {Partial<SyncSelections> | null | undefined} raw
29
- * @param {{ includeAuthFile?: boolean }} [options]
31
+ * @param {{ includeProviderCredentials?: boolean }} [options]
30
32
  * @returns {SyncSelections}
31
33
  */
32
34
  export const normalizeSyncSelections = (raw, options = {}) => {
@@ -48,7 +50,8 @@ export const normalizeSyncSelections = (raw, options = {}) => {
48
50
  singleFiles,
49
51
  directories,
50
52
  agentsRoot: raw.agentsRoot !== false,
51
- authFile: raw.authFile === true,
53
+ providerCredentials: raw.providerCredentials === true,
54
+ magpie: raw.magpie === true,
52
55
  };
53
56
  };
54
57
 
@@ -59,7 +62,7 @@ export const normalizeSyncSelections = (raw, options = {}) => {
59
62
  * @param {SyncSelections} selections
60
63
  */
61
64
  export const filterPlanBySelections = (plan, selections) => {
62
- const selected = normalizeSyncSelections(selections, { includeAuthFile: selections?.authFile === true });
65
+ const selected = normalizeSyncSelections(selections, { includeProviderCredentials: selections?.providerCredentials === true });
63
66
  const selectedFileNames = new Set();
64
67
  const selectedDeleteNames = new Set();
65
68
 
@@ -91,13 +94,13 @@ export const filterPlanBySelections = (plan, selections) => {
91
94
  const directories = (Array.isArray(plan?.directories) ? plan.directories : []).filter((entry) => selectedDirNames.has(entry.path));
92
95
  const deletes = (Array.isArray(plan?.deletes) ? plan.deletes : []).filter((name) => selectedDeleteNames.has(name) || selectedDirLegacy.has(name));
93
96
  const agentsRoot = selected.agentsRoot ? (plan?.agentsRoot ?? null) : null;
94
- const authFile = selected.authFile ? (plan?.authFile ?? null) : null;
97
+ const providerCredentials = selected.providerCredentials ? (plan?.providerCredentials ?? null) : null;
95
98
 
96
99
  let totalBytes = 0;
97
100
  for (const entry of files) totalBytes += Number(entry.bytes) || 0;
98
101
  for (const entry of directories) totalBytes += Number(entry.bytes) || 0;
99
102
  if (agentsRoot) totalBytes += Number(agentsRoot.bytes) || 0;
100
- if (authFile) totalBytes += Number(authFile.bytes) || 0;
103
+ if (providerCredentials) totalBytes += Number(providerCredentials.bytes) || 0;
101
104
 
102
105
  return {
103
106
  ...plan,
@@ -105,7 +108,8 @@ export const filterPlanBySelections = (plan, selections) => {
105
108
  directories,
106
109
  deletes,
107
110
  agentsRoot,
108
- authFile,
111
+ providerCredentials,
112
+ magpie: selected.magpie && plan?.magpieAvailable ? { bytes: 0 } : null,
109
113
  totalBytes,
110
114
  selections: selected,
111
115
  };
@@ -13,6 +13,11 @@ Android notification-only subscriptions send the App locale in `Accept-Language`
13
13
  - `packages/web/server/lib/notifications/apns-runtime.js`: native iOS APNs device-token persistence + delivery, plus a separate Live Activity token store and `end` send path. Two modes: **relay** (default — sign + POST tokens + generic text to the isolated Push Relay in `packages/relay-server/src/push/`, which holds the project APNs key) and **direct** (fallback — sign ES256 JWT with Node crypto + HTTP/2, when `OPENCHAMBER_PUSH_RELAY_DISABLED=true`). Unless `OPENCHAMBER_PUSH_RELAY_URL` is set, the Host maps the effective Relay `wss`/`ws` URL to the same host as `https`/`http` `/v1/push/send`. Before each relay send, the runtime checks an in-memory registration success cache (keyed by current register URL + token + platform) and only POSTs `/v1/push/register-token` for cache misses; only successfully bound tokens are sent. Switching Relay/Push URL clears that cache so persisted tokens re-bind to the new origin on the next send or `reRegisterAllTokens()`. Each server has an auto-generated ECDSA P-256 keypair (`getOrCreateRelayKeypair`, persisted in settings); it binds tokens on the relay (`/v1/push/register-token`) and signs every relay request, so the relay only delivers to tokens bound to that server. APNs is the iOS app's sole notification channel (no local notifications) and is NOT gated on UI visibility — iOS suppresses the foreground banner instead. APNs push carries only generic text (scenario title + session name) — see `APNS.md`. Android uses the separate native notification stream described above. Live Activity tokens are persisted beside alert tokens in the same file (`version` 2, `liveActivityTokensBySession`) without mixing the two token types; v1 files remain readable. Live Activity relay bind/send uses `/v1/push/register-live-activity-token`, `/v1/push/unregister-live-activity-token`, and `/v1/push/live-activity`.
14
14
  - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime.
15
15
  - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout.
16
+ - Assistant-owned scheduled results first enter the assistant's read-only result
17
+ review. They bypass generic scheduled-task fanout: a spoken review uses the
18
+ existing contact notification path, while an explicit silent review sends
19
+ nothing. Unowned tasks retain `notifyOnScheduledTasks` and the manual-run gate.
20
+ Result handoff/recovery belongs to `scheduled-tasks`, not this notification module.
16
21
  - `family-completion-gate.js` (with a TypeScript declaration): dependency-free, runtime-local completion barrier shared by Host fanout and the VS Code webview. The Host supplies authoritative HTTP reads; VS Code supplies official SDK reads.
17
22
  - `packages/web/server/lib/notifications/live-activity-refresh-runtime.js`: periodic Live Activity snapshot refresh (default 30s, `OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS` overrides, `0` disables). Each tick snapshots the authoritative session states once, recomputes every persisted Live Activity token's snapshot, and pushes updates only for real changes — busy→retry transitions, newly visible top-level sessions, idle completions the event path missed, and stale-row recovery. Entries whose recomputed snapshot is unchanged never send (APNs budgets Live Activity updates per hour). Rows for sessions without authoritative server state are preserved as-is; server-side-only sub-states (`permission`/`input`/`tool`) keep their last value while the session stays busy. When no row is working anymore the refresh sends `end` and clears accepted tokens.
18
23
  - `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only.
@@ -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.
@@ -73,11 +75,12 @@ This module provides OpenCode server integration utilities for the web server ru
73
75
 
74
76
  ## Public exports (auth.js)
75
77
  - `configureOpenCodeCredentials(resolveClient)`: host composition injects a call-time SDK client resolver; `null` detaches it. Web/Electron share server wiring; VS Code wires its manager in extension activation. Mobile clients use their host.
78
+ - `currentClient()`: trusted host-only client resolver used by config-sync to retain full V2 credential account records instead of the active-account quota projection.
76
79
  - `openCodeCredentialSource({ buildOpenCodeUrl, getOpenCodeAuthHeaders })`: resolves the current endpoint/auth at each read rather than capturing a previous runtime.
77
80
  - `readOpenCodeCredentials()`: async SDK `credential.list()` read of the running service; validates entries and projects only active accounts into the quota/TTS credential shape. OAuth `metadata.accountID` becomes `accountId`. No local `auth.json`, local environment, or local provider-config fallback; external service environment-only keys are not exposed by the credential API.
78
81
  - `getProviderAuth(providerId)`: async selected stored credential or authoritative `null`; network/auth/schema failures reject with a sanitized error, never empty success.
79
82
  - `removeProviderAuth(providerId)`: deletes every stored account for that integration through SDK `credential.remove`, preserving unrelated integrations. Partial failure rejects explicitly; retry lists remaining accounts. Auth-only disconnect requires no process restart.
80
- - Credential values remain host-only. `credential-boundary.js` blocks raw credential list/creation responses (GET/HEAD/POST `/api/credential`, including encoded/trailing-slash forms) in Web proxy and VS Code bridge because v2 replies contain keys. Provider-source responses expose only existence; errors never include upstream credential bodies/headers. Explicit legacy config-sync file transfer is not a current-service credential source or login operation.
83
+ - Credential values remain host-only outside explicitly selected instance sync. `credential-boundary.js` blocks raw credential list/creation responses (GET/HEAD/POST `/api/credential`, including encoded/trailing-slash forms) in Web proxy and VS Code bridge because v2 replies contain keys. Provider-source responses expose only existence; errors never include upstream credential bodies/headers. The authenticated config-sync routes own explicit V2 credential transfer; previews and run logs contain only metadata.
81
84
 
82
85
  ## Public exports (shared.js)
83
86
  - `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
@@ -4,6 +4,7 @@ export type ProviderCredential =
4
4
  | { type: 'api'; key: string }
5
5
  | { type: 'oauth'; access: string; refresh: string; expires: number; accountId?: string; enterpriseUrl?: string };
6
6
  export function configureOpenCodeCredentials(resolveClient: (() => OpenCodeClient) | null): void;
7
+ export function currentClient(): OpenCodeClient;
7
8
  export function openCodeCredentialSource(deps: {
8
9
  buildOpenCodeUrl: (path: string, prefix?: string) => string;
9
10
  getOpenCodeAuthHeaders: () => Record<string, string>;
@@ -49,7 +49,7 @@ async function listCredentials(client) {
49
49
  }
50
50
  }
51
51
 
52
- function currentClient() {
52
+ export function currentClient() {
53
53
  if (!getClient) throw credentialError();
54
54
  try { return getClient(); } catch { throw credentialError(); }
55
55
  }
@@ -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
  });
@@ -337,6 +337,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
337
337
  onSystemSessionPersisted,
338
338
  upsertScheduledTask: (projectID, task) => projectConfigRuntime.upsertScheduledTask(projectID, task),
339
339
  syncScheduledTaskProject: (projectID) => scheduledTasksRuntime.syncProject(projectID),
340
+ setTaskResultHandler: (handler) => scheduledTasksRuntime.setTaskResultHandler(handler),
340
341
  refreshAllowedRoots: refreshAssistantAllowedRoots,
341
342
  globalEventHub: globalMessageStreamHub,
342
343
  onRevisionTip: (tip) => broadcastAssistantRevisionTip({
@@ -521,6 +522,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
521
522
  fsPromises,
522
523
  path,
523
524
  openchamberDataDir,
525
+ broadcastOpenChamberEvent,
524
526
  });
525
527
  registerFsRoutes(app, {
526
528
  os,
@@ -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) {