@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
@@ -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', () => {
@@ -10,6 +10,31 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
10
10
  - This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
11
11
  - Prompt and goal execution preserve hash-prefixed text literally; no snippet discovery or expansion occurs before dispatch.
12
12
 
13
+ ## Assistant result handoff
14
+
15
+ `setTaskResultHandler(handler)` installs the Host's assistant result consumer and
16
+ returns its cleanup function. The feature-route composition wires it to the
17
+ assistant service. A handler returns `true` once associated contacts have reviewed
18
+ the result (including explicit silence); `false` retains ordinary scheduled-task
19
+ notification delivery. The scheduler releases its execution slot independently
20
+ of assistant inference. Handler failures keep the result pending.
21
+
22
+ Run history stores the original execution prompt privately alongside a
23
+ `result_pending` marker. The same terminal history update makes the result
24
+ eligible for delivery, including an in-flight run reconciled after restart.
25
+ `listPendingResults` uses bounded keyset pages of 20; the existing five-second
26
+ recovery pass retries handoffs with at most four in flight per runtime. A failing
27
+ page does not prevent the cursor from reaching later results. `acknowledgeResult`
28
+ clears only the delivery marker, never the execution result. Execution failure,
29
+ history-persistence failure, and assistant delivery failure remain distinct.
30
+
31
+ The additive migration leaves already-terminal old history acknowledged, avoiding
32
+ an upgrade notification backlog. Old running records enroll when they really
33
+ settle. Public history DTOs expose neither the prompt nor internal delivery state.
34
+ Assistant receipts deduplicate completed reviews, including silent ones; this is
35
+ a retryable handoff, not an exactly-once guarantee for model requests or device
36
+ pushes during crashes/concurrent Hosts. Result review is read-only.
37
+
13
38
  ## Built-in new-session skill
14
39
 
15
40
  `session-skill.js` intercepts new `POST /api/session` admissions on a Host using
@@ -113,6 +113,12 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
113
113
  if (!hasSlotAt) {
114
114
  db.exec('ALTER TABLE scheduled_task_run ADD COLUMN slot_at INTEGER');
115
115
  }
116
+ db.transaction(() => {
117
+ const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('scheduled_task_run')").all().map((row) => row.name));
118
+ if (!columns.has('result_pending')) db.exec('ALTER TABLE scheduled_task_run ADD COLUMN result_pending INTEGER NOT NULL DEFAULT 0');
119
+ if (!columns.has('execution_prompt')) db.exec('ALTER TABLE scheduled_task_run ADD COLUMN execution_prompt TEXT');
120
+ db.exec("CREATE INDEX IF NOT EXISTS scheduled_task_run_pending_result ON scheduled_task_run(started_at DESC, run_id DESC) WHERE result_pending=1 AND status IN ('success','error')");
121
+ })();
116
122
  db.exec(`
117
123
  CREATE UNIQUE INDEX IF NOT EXISTS scheduled_task_run_scheduled_slot
118
124
  ON scheduled_task_run (project_id, task_id, slot_at)
@@ -161,9 +167,9 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
161
167
  db.prepare(`
162
168
  INSERT INTO scheduled_task_run (
163
169
  run_id, project_id, task_id, task_name, trigger, status,
164
- session_id, directory, error, started_at, finished_at, duration_ms, slot_at
165
- ) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?)
166
- `).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt);
170
+ session_id, directory, error, started_at, finished_at, duration_ms, slot_at, result_pending, execution_prompt
171
+ ) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?, 1, ?)
172
+ `).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt, typeof record.prompt === 'string' ? record.prompt : null);
167
173
  } catch (error) {
168
174
  if (slotAt !== null && SQLITE_UNIQUE_CODES.has(error?.code)) {
169
175
  const claimed = new Error('scheduled slot already claimed');
@@ -220,6 +226,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
220
226
  const updated = db.prepare(`
221
227
  UPDATE scheduled_task_run
222
228
  SET status = ?,
229
+ result_pending = CASE WHEN status='running' THEN 1 ELSE result_pending END,
223
230
  session_id = ?,
224
231
  error = ?,
225
232
  finished_at = ?,
@@ -231,7 +238,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
231
238
  return rowToDto(db.prepare('SELECT * FROM scheduled_task_run WHERE run_id = ?').get(id));
232
239
  };
233
240
 
234
- const listRuns = ({ before, limit, projectID, taskID, projectId, taskId } = {}, runningOnly = false) => {
241
+ const listRuns = ({ before, limit, projectID, taskID, projectId, taskId } = {}, runningOnly = false, pendingOnly = false) => {
235
242
  assertOpen();
236
243
  const cursor = decodeCursor(before);
237
244
  const resolvedLimit = (() => {
@@ -249,6 +256,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
249
256
  if (runningOnly) {
250
257
  filters.push("status = 'running'");
251
258
  }
259
+ if (pendingOnly) filters.push("result_pending=1 AND status IN ('success','error')");
252
260
  const params = [];
253
261
  const resolvedProjectID = asNonEmptyString(projectID ?? projectId);
254
262
  const resolvedTaskID = asNonEmptyString(taskID ?? taskId);
@@ -278,7 +286,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
278
286
  const hasMore = rows.length > resolvedLimit;
279
287
  const last = pageRows[pageRows.length - 1];
280
288
  return {
281
- runs: pageRows.map(rowToDto),
289
+ runs: pageRows.map((row) => pendingOnly ? { ...rowToDto(row), prompt: row.execution_prompt } : rowToDto(row)),
282
290
  nextCursor: hasMore && last
283
291
  ? encodeCursor({ startedAt: last.started_at, runID: last.run_id })
284
292
  : null,
@@ -300,6 +308,11 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
300
308
  finishRun,
301
309
  listRuns,
302
310
  listRunningRuns: (options) => listRuns(options, true),
311
+ listPendingResults: (options) => listRuns(options, false, true),
312
+ acknowledgeResult: (runID) => {
313
+ assertOpen();
314
+ db.prepare("UPDATE scheduled_task_run SET result_pending=0 WHERE run_id=? AND status IN ('success','error')").run(runID);
315
+ },
303
316
  close,
304
317
  };
305
318
  };
@@ -33,6 +33,57 @@ const startSample = (store, overrides = {}) => store.startRun({
33
33
  });
34
34
 
35
35
  describe('scheduled task run history store', () => {
36
+ it('keeps terminal result handoffs durable, with the execution prompt private and running runs excluded', () => {
37
+ const dbPath = createTempDbPath();
38
+ let store = createScheduledTaskRunHistoryStore({ dbPath });
39
+ try {
40
+ store.startRun({ id: 'report', projectId: 'p', taskId: 't', prompt: 'Deliver research findings' });
41
+ expect(store.listPendingResults().runs).toEqual([]);
42
+ store.finishRun('report', { status: 'success' });
43
+ expect(store.listRuns().runs[0]).not.toHaveProperty('prompt');
44
+ store.close();
45
+ store = createScheduledTaskRunHistoryStore({ dbPath });
46
+ expect(store.listPendingResults().runs).toEqual([expect.objectContaining({ id: 'report', prompt: 'Deliver research findings' })]);
47
+ store.acknowledgeResult('report');
48
+ store.close();
49
+ store = createScheduledTaskRunHistoryStore({ dbPath });
50
+ expect(store.listPendingResults().runs).toEqual([]);
51
+ } finally { store.close(); }
52
+ });
53
+
54
+ it('migrates old history without replaying completed runs and enrolls recovered in-flight work', () => {
55
+ const dbPath = createTempDbPath();
56
+ const first = createScheduledTaskRunHistoryStore({ dbPath });
57
+ startSample(first, { id: 'old-complete' });
58
+ first.finishRun('old-complete', { status: 'success' });
59
+ startSample(first, { id: 'old-running' });
60
+ first.close();
61
+ const Database = require('better-sqlite3');
62
+ const db = new Database(dbPath);
63
+ db.exec('DROP INDEX scheduled_task_run_pending_result; ALTER TABLE scheduled_task_run DROP COLUMN result_pending; ALTER TABLE scheduled_task_run DROP COLUMN execution_prompt');
64
+ db.close();
65
+ const store = createScheduledTaskRunHistoryStore({ dbPath });
66
+ try {
67
+ expect(store.listPendingResults().runs).toEqual([]);
68
+ store.finishRun('old-running', { status: 'error', error: 'actual failure' });
69
+ expect(store.listPendingResults().runs).toEqual([expect.objectContaining({ id: 'old-running', status: 'error', prompt: null })]);
70
+ } finally { store.close(); }
71
+ });
72
+
73
+ it('does not publish a result when the terminal history write rolls back', () => {
74
+ const dbPath = createTempDbPath();
75
+ const store = createScheduledTaskRunHistoryStore({ dbPath });
76
+ startSample(store, { id: 'failed-write' });
77
+ const Database = require('better-sqlite3');
78
+ const db = new Database(dbPath);
79
+ db.exec("CREATE TRIGGER reject_finish BEFORE UPDATE OF status ON scheduled_task_run BEGIN SELECT RAISE(ABORT, 'disk write failed'); END");
80
+ try {
81
+ expect(() => store.finishRun('failed-write', { status: 'success' })).toThrow('disk write failed');
82
+ expect(store.listRuns().runs[0].status).toBe('running');
83
+ expect(store.listPendingResults().runs).toEqual([]);
84
+ } finally { db.close(); store.close(); }
85
+ });
86
+
36
87
  it('lists runs newest-first and supports stable keyset pagination', () => {
37
88
  let now = 1_000;
38
89
  const store = createScheduledTaskRunHistoryStore({
@@ -1327,7 +1327,53 @@ export const createScheduledTasksRuntime = (deps) => {
1327
1327
  runID,
1328
1328
  error: safeErrorMessage(error),
1329
1329
  });
1330
- return { error };
1330
+ return { persistenceError: error };
1331
+ }
1332
+ };
1333
+
1334
+ let taskResultHandler = null;
1335
+ let resultRecoveryCursor;
1336
+ let resultRecoveryFlight = false;
1337
+ const deliveringResults = new Set();
1338
+ const deliverTaskResult = async (run) => {
1339
+ if (deliveringResults.has(run.id) || deliveringResults.size >= 4) return;
1340
+ deliveringResults.add(run.id);
1341
+ const handler = taskResultHandler;
1342
+ try {
1343
+ const handled = handler ? await handler(run) : false;
1344
+ if (handler !== taskResultHandler) return;
1345
+ if (!handled) {
1346
+ await notifyTaskRun?.({
1347
+ projectID: run.projectId, taskID: run.taskId, taskName: run.taskName,
1348
+ status: run.status, reason: run.trigger,
1349
+ ...(run.sessionId ? { sessionId: run.sessionId } : {}),
1350
+ ...(run.error ? { errorMessage: run.error } : {}),
1351
+ });
1352
+ }
1353
+ runHistoryStore?.acknowledgeResult?.(run.id);
1354
+ } catch (error) {
1355
+ logger.warn?.('[ScheduledTasks] result delivery failed', { runID: run.id, error: safeErrorMessage(error) });
1356
+ } finally {
1357
+ deliveringResults.delete(run.id);
1358
+ }
1359
+ };
1360
+ const recoverTaskResults = async () => {
1361
+ if (resultRecoveryFlight || !taskResultHandler || typeof runHistoryStore?.listPendingResults !== 'function') return;
1362
+ resultRecoveryFlight = true;
1363
+ try {
1364
+ const page = runHistoryStore.listPendingResults({ before: resultRecoveryCursor, limit: 20 });
1365
+ resultRecoveryCursor = page.nextCursor || undefined;
1366
+ let index = 0;
1367
+ await Promise.all(Array.from({ length: Math.min(4, page.runs.length) }, async () => {
1368
+ while (index < page.runs.length && taskResultHandler) {
1369
+ const run = page.runs[index++];
1370
+ await deliverTaskResult(run);
1371
+ }
1372
+ }));
1373
+ } catch (error) {
1374
+ logger.warn?.('[ScheduledTasks] result recovery failed', { error: safeErrorMessage(error) });
1375
+ } finally {
1376
+ resultRecoveryFlight = false;
1331
1377
  }
1332
1378
  };
1333
1379
 
@@ -1375,6 +1421,7 @@ export const createScheduledTasksRuntime = (deps) => {
1375
1421
  trigger: reason === 'manual' ? 'manual' : 'scheduled',
1376
1422
  directory: projectPath,
1377
1423
  startedAt: runStartedAt,
1424
+ prompt: task.execution?.prompt,
1378
1425
  ...(resolvedSlotAt !== undefined ? { slotAt: resolvedSlotAt } : {}),
1379
1426
  });
1380
1427
  } catch (error) {
@@ -1587,9 +1634,9 @@ export const createScheduledTasksRuntime = (deps) => {
1587
1634
  ...(sessionID ? { sessionId: sessionID } : {}),
1588
1635
  ...(status === 'error' ? { error: errorMessage || 'Unknown error' } : {}),
1589
1636
  });
1590
- if (historyFinalize?.error) {
1637
+ if (historyFinalize?.persistenceError) {
1591
1638
  status = 'error';
1592
- errorMessage = errorMessage || safeErrorMessage(historyFinalize.error);
1639
+ errorMessage = errorMessage || safeErrorMessage(historyFinalize.persistenceError);
1593
1640
  }
1594
1641
 
1595
1642
  try {
@@ -1606,15 +1653,21 @@ export const createScheduledTasksRuntime = (deps) => {
1606
1653
  // Notification fanout for scheduled (non-manual) runs; the notification
1607
1654
  // runtime owns every delivery gate (settings toggle, channels).
1608
1655
  try {
1609
- await notifyTaskRun?.({
1610
- projectID,
1611
- taskID,
1612
- taskName,
1613
- status,
1614
- reason,
1615
- ...(sessionID ? { sessionId: sessionID } : {}),
1616
- ...(status === 'error' ? { errorMessage } : {}),
1617
- });
1656
+ if (taskResultHandler && historyFinalize && !historyFinalize.persistenceError) {
1657
+ // Reviewing a result is independent of the scheduler's execution slot.
1658
+ void deliverTaskResult({ ...historyFinalize, prompt: task.execution?.prompt });
1659
+ } else {
1660
+ await notifyTaskRun?.({
1661
+ projectID,
1662
+ taskID,
1663
+ taskName,
1664
+ status,
1665
+ reason,
1666
+ ...(sessionID ? { sessionId: sessionID } : {}),
1667
+ ...(status === 'error' ? { errorMessage } : {}),
1668
+ });
1669
+ if (!historyFinalize?.persistenceError && runID) runHistoryStore?.acknowledgeResult?.(runID);
1670
+ }
1618
1671
  } catch (error) {
1619
1672
  logger.warn?.('[ScheduledTasks] task-run notification failed', {
1620
1673
  projectID,
@@ -1813,6 +1866,7 @@ export const createScheduledTasksRuntime = (deps) => {
1813
1866
  if (controller.signal.aborted) return;
1814
1867
  await recoverTaskStates(controller);
1815
1868
  if (controller.signal.aborted) return;
1869
+ void recoverTaskResults();
1816
1870
  historyRecoveryTimer = setTimeout(() => {
1817
1871
  void recoverRunHistory(controller, nextCursor || undefined);
1818
1872
  }, 5_000);
@@ -1994,5 +2048,9 @@ export const createScheduledTasksRuntime = (deps) => {
1994
2048
  runNow,
1995
2049
  getStatus,
1996
2050
  observeSessionEvent,
2051
+ setTaskResultHandler: (handler) => {
2052
+ taskResultHandler = handler;
2053
+ return () => { if (taskResultHandler === handler) taskResultHandler = null; };
2054
+ },
1997
2055
  };
1998
2056
  };
@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { dirname, join } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { createAssistantsService } from '../assistants/service.js';
9
+ import { runContactTurn as reviewTaskResult } from '../assistants/harness.js';
8
10
 
9
11
  vi.mock('../opencode/v2-client.js', () => ({
10
12
  makeOpenCodeV2Client: vi.fn(),
@@ -318,6 +320,135 @@ describe('scheduled-tasks history recovery', () => {
318
320
  });
319
321
  });
320
322
 
323
+ describe('scheduled task result handoff', () => {
324
+ it.each(['report', 'silent', 'failure'])('connects a real scheduled run to the owner contact with %s delivery', async (mode) => {
325
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduled-contact-'));
326
+ const store = createScheduledTaskRunHistoryStore({ dbPath: path.join(directory, 'runs.sqlite') });
327
+ let task = { ...scheduledTask, execution: { ...scheduledTask.execution, prompt: mode === 'report' ? 'Research and send a summary' : 'Do background work; no success report needed' } };
328
+ let releaseWorker;
329
+ const workerResult = new Promise((resolve) => { releaseWorker = resolve; });
330
+ const messageList = vi.fn(() => workerResult);
331
+ const client = {
332
+ session: {
333
+ create: vi.fn(async () => ({ id: 'worker' })),
334
+ prompt: vi.fn(async () => ({ id: 'inbox', type: 'user' })),
335
+ active: vi.fn(async () => ({})),
336
+ },
337
+ message: { list: messageList },
338
+ provider: { list: async () => ({ data: [] }) },
339
+ model: { list: async () => ({ data: [] }) },
340
+ };
341
+ makeOpenCodeV2Client.mockReturnValue(client);
342
+ const listProjects = async () => [{ id: 'project-1', path: directory }];
343
+ const projectConfigRuntime = {
344
+ listScheduledTasks: async () => [task],
345
+ upsertScheduledTask: async (_project, input) => ({ task: { ...task, ...input, id: 'task-1' } }),
346
+ updateScheduledTaskState: async (_project, _id, state) => {
347
+ task = { ...task, state: { ...task.state, ...state } };
348
+ return { task };
349
+ },
350
+ };
351
+ const notifyTaskRun = vi.fn();
352
+ const onContactTurnComplete = vi.fn();
353
+ const reviews = vi.fn(async (input) => {
354
+ expect(input.userText).toContain(task.execution.prompt);
355
+ if (mode === 'failure') expect(input.userText).toContain('upstream failed');
356
+ else expect(input.userText).toContain('Actual research findings');
357
+ return reviewTaskResult({ ...input, createChatCompletion: async () => ({ text: mode === 'silent'
358
+ ? '```openchamber-final\n{"status":"silent","text":""}\n```'
359
+ : mode === 'failure' ? '任务执行失败,需要检查服务。' : '资料汇总:有两项值得关注的更新。' }) });
360
+ });
361
+ const runtime = createRuntime(null, {
362
+ projectConfigRuntime, listProjects, runHistoryStore: store, notifyTaskRun,
363
+ waitForOpenCodeReady: async () => {},
364
+ });
365
+ const service = createAssistantsService({
366
+ dbPath: path.join(directory, 'assistants.sqlite'), dataDir: directory,
367
+ getAllowedRoots: () => [directory], listProjects, clientFactory: () => client,
368
+ buildOpenCodeUrl: () => 'http://127.0.0.1:4096', getOpenCodeAuthHeaders: () => ({}),
369
+ upsertScheduledTask: projectConfigRuntime.upsertScheduledTask,
370
+ syncScheduledTaskProject: (id) => runtime.syncProject(id),
371
+ onContactTurnComplete,
372
+ runContactTurn: async (input) => {
373
+ if (input.readOnly) return reviews(input);
374
+ await input.tools.find((tool) => tool.name === 'schedule_task').execute('schedule', {
375
+ name: 'Task', prompt: task.execution.prompt, time: '23:59', timezone: 'UTC',
376
+ });
377
+ return { bubbles: ['已安排'] };
378
+ },
379
+ });
380
+ const detach = runtime.setTaskResultHandler((run) => service.reportScheduledTaskResult(run));
381
+ try {
382
+ service.setEnabled({ enabled: true, expectedRevision: service.snapshot().revision });
383
+ const owner = service.createAssistant({ name: 'Research', providerID: 'openai', modelID: 'gpt-4.1' });
384
+ const sent = await service.send(owner.id, { messageID: 'schedule', parts: [{ type: 'text', text: 'Create the recurring task' }] });
385
+ await service.whenContactTurnSettled(sent.messageID);
386
+ onContactTurnComplete.mockClear();
387
+ const run = runtime.runNow('project-1', 'task-1');
388
+ await vi.waitFor(() => expect(messageList).toHaveBeenCalled());
389
+ expect(reviews).not.toHaveBeenCalled();
390
+ releaseWorker({ data: [{ id: 'result', type: 'assistant', text: 'Actual research findings', time: { created: 1, completed: 2 }, finish: 'stop', ...(mode === 'failure' ? { error: { name: 'ProviderError', message: 'upstream failed' } } : {}) }] });
391
+ const result = await run;
392
+ expect(result.status).toBe(mode === 'failure' ? 'error' : 'success');
393
+ await vi.waitFor(() => expect(store.listPendingResults().runs).toEqual([]));
394
+ expect(reviews).toHaveBeenCalledOnce();
395
+ expect(notifyTaskRun).not.toHaveBeenCalled();
396
+ expect(onContactTurnComplete).toHaveBeenCalledTimes(mode === 'silent' ? 0 : 1);
397
+ expect(service.snapshot().assistants[0].working).toBe(false);
398
+ } finally {
399
+ detach(); runtime.stop(); service.close(); store.close();
400
+ fs.rmSync(directory, { recursive: true, force: true });
401
+ }
402
+ });
403
+
404
+ it('recovers durable results after restart and isolates a failed handoff from other runs', async () => {
405
+ vi.useFakeTimers();
406
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduled-result-recovery-'));
407
+ const dbPath = path.join(directory, 'runs.sqlite');
408
+ let store = createScheduledTaskRunHistoryStore({ dbPath });
409
+ const retryIDs = Array.from({ length: 4 }, (_, index) => `z-retry-${index}`);
410
+ const healthyIDs = Array.from({ length: 21 }, (_, index) => `healthy-${index}`);
411
+ for (const id of [...retryIDs, ...healthyIDs, 'unowned']) {
412
+ store.startRun({ id, projectId: 'project-1', taskId: 'task-1', prompt: 'Original instruction' });
413
+ store.finishRun(id, { status: 'success' });
414
+ }
415
+ store.close();
416
+ store = createScheduledTaskRunHistoryStore({ dbPath });
417
+ let broken = true;
418
+ let active = 0;
419
+ let peak = 0;
420
+ const handler = vi.fn(async (run) => {
421
+ expect(run.prompt).toBe('Original instruction');
422
+ active += 1;
423
+ peak = Math.max(peak, active);
424
+ try {
425
+ await Promise.resolve();
426
+ if (retryIDs.includes(run.id) && broken) throw new Error('temporary handoff failure');
427
+ return run.id !== 'unowned';
428
+ } finally { active -= 1; }
429
+ });
430
+ const notifyTaskRun = vi.fn();
431
+ const runtime = createRuntime(vi.fn(async () => ({ task: scheduledTask })), { runHistoryStore: store, notifyTaskRun });
432
+ const detach = runtime.setTaskResultHandler(handler);
433
+ try {
434
+ await runtime.start();
435
+ await vi.advanceTimersByTimeAsync(0);
436
+ await vi.advanceTimersByTimeAsync(5000);
437
+ expect(store.listPendingResults().runs.map((run) => run.id).sort()).toEqual(retryIDs);
438
+ expect(notifyTaskRun).toHaveBeenCalledOnce();
439
+ broken = false;
440
+ await vi.advanceTimersByTimeAsync(5000);
441
+ expect(store.listPendingResults().runs).toEqual([]);
442
+ for (const id of healthyIDs) expect(handler.mock.calls.filter(([run]) => run.id === id)).toHaveLength(1);
443
+ expect(peak).toBeLessThanOrEqual(4);
444
+ } finally {
445
+ detach(); runtime.stop(); store.close();
446
+ fs.rmSync(directory, { recursive: true, force: true });
447
+ vi.useRealTimers();
448
+ }
449
+ });
450
+ });
451
+
321
452
  describe('scheduled-tasks runtime helpers', () => {
322
453
  it('computes next daily run in timezone', () => {
323
454
  const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0);
@@ -0,0 +1,20 @@
1
+ # Session folders and order
2
+
3
+ Folder membership/collapse retains `/api/session-folders` and
4
+ `sessions-directories.json`. Session ordering uses `/api/session-folders/order`
5
+ and `session-order.json` independently so an older folder client cannot erase it.
6
+
7
+ GET returns `{ revision, sessionOrderByScope, sessionOrderActivityByScope }`.
8
+ POST replaces one scope using `{ scopeKey, order, activity, onlyIfAbsent? }`.
9
+ Directory paths identify ordinary lists; `__pinned__` identifies global pins.
10
+ Writes serialize per Host, merge other scopes, atomically rename the new file,
11
+ then broadcast `openchamber:session-order-changed` through the product SSE stream.
12
+ Last committed drag wins within a scope; independent scopes never replace each
13
+ other. A failed write leaves the previous file and emits no success event.
14
+ Missing files mean an empty initial catalog; malformed/unreadable files fail.
15
+
16
+ `onlyIfAbsent` imports pre-existing local ranks without overwriting a scope
17
+ already saved by another device. It does not bump revision or emit a tip when
18
+ the scope already exists. Local caches are startup continuity, not authority.
19
+ Clients fetch first and refetch on tips/reconnect/foreground through the shared
20
+ runtime HTTP/SSE transport (direct and Relay use identical routes).
@@ -0,0 +1,69 @@
1
+ const emptySnapshot = () => ({ revision: 0, sessionOrderByScope: {}, sessionOrderActivityByScope: {} });
2
+
3
+ export function registerSessionOrderRoutes(app, { fsPromises: fs, path, openchamberDataDir, broadcastOpenChamberEvent }) {
4
+ const file = path.join(openchamberDataDir, 'session-order.json');
5
+ let writes = Promise.resolve();
6
+ const read = async () => {
7
+ try {
8
+ const snapshot = JSON.parse(await fs.readFile(file, 'utf8'));
9
+ if (!Number.isSafeInteger(snapshot?.revision) || snapshot.revision < 0
10
+ || !snapshot.sessionOrderByScope || typeof snapshot.sessionOrderByScope !== 'object' || Array.isArray(snapshot.sessionOrderByScope)
11
+ || !snapshot.sessionOrderActivityByScope || typeof snapshot.sessionOrderActivityByScope !== 'object' || Array.isArray(snapshot.sessionOrderActivityByScope)) {
12
+ throw new Error('Invalid session order file');
13
+ }
14
+ return snapshot;
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return emptySnapshot();
17
+ throw error;
18
+ }
19
+ };
20
+ app.get('/api/session-folders/order', async (_req, res) => {
21
+ try {
22
+ await writes;
23
+ res.json(await read());
24
+ } catch (error) {
25
+ res.status(500).json({ error: error.message });
26
+ }
27
+ });
28
+ app.post('/api/session-folders/order', async (req, res) => {
29
+ const { scopeKey, order, activity, onlyIfAbsent } = req.body ?? {};
30
+ if (typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4096
31
+ || !Array.isArray(order) || order.length > 10000
32
+ || !order.every((id) => typeof id === 'string' && id.length > 0 && id.length < 512)
33
+ || !activity || typeof activity !== 'object' || Array.isArray(activity)
34
+ || Object.keys(activity).length > 10000
35
+ || !Object.values(activity).every((value) => typeof value === 'number' && Number.isFinite(value))
36
+ || (onlyIfAbsent !== undefined && typeof onlyIfAbsent !== 'boolean')) {
37
+ return res.status(400).json({ error: 'Invalid session order' });
38
+ }
39
+ const write = writes.then(async () => {
40
+ const current = await read();
41
+ if (onlyIfAbsent && Object.hasOwn(current.sessionOrderByScope, scopeKey)) return current;
42
+ const next = {
43
+ revision: current.revision + 1,
44
+ sessionOrderByScope: { ...current.sessionOrderByScope, [scopeKey]: [...new Set(order)] },
45
+ sessionOrderActivityByScope: { ...current.sessionOrderActivityByScope, [scopeKey]: activity },
46
+ };
47
+ await fs.mkdir(openchamberDataDir, { recursive: true });
48
+ const tmp = `${file}.tmp-${process.pid}-${next.revision}`;
49
+ try {
50
+ await fs.writeFile(tmp, JSON.stringify(next), 'utf8');
51
+ await fs.rename(tmp, file);
52
+ } catch (error) {
53
+ await fs.unlink(tmp).catch(() => {});
54
+ throw error;
55
+ }
56
+ broadcastOpenChamberEvent?.({
57
+ type: 'openchamber:session-order-changed',
58
+ properties: { occurredAt: Date.now() },
59
+ });
60
+ return next;
61
+ });
62
+ writes = write.then(() => {}, () => {});
63
+ try {
64
+ res.json(await write);
65
+ } catch (error) {
66
+ res.status(500).json({ error: error.message });
67
+ }
68
+ });
69
+ }
@@ -1,6 +1,9 @@
1
+ import { registerSessionOrderRoutes } from './order-routes.js';
2
+
1
3
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
2
4
 
3
5
  export const registerSessionFoldersRoutes = (app, dependencies) => {
6
+ registerSessionOrderRoutes(app, dependencies);
4
7
  const {
5
8
  fsPromises,
6
9
  path,