@openchambery/web 1.19.21-beta.1 → 2.0.0-beta.1

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 (195) hide show
  1. package/README.md +13 -11
  2. package/bin/cli-opencode.test.js +208 -0
  3. package/bin/cli.js +62 -9
  4. package/bin/lib/cli-startup.js +105 -2
  5. package/bin/lib/cli-startup.test.js +165 -0
  6. package/dist/assets/{AgentAvatar-NT957MuI.js → AgentAvatar-CLWWNu_Q.js} +1 -1
  7. package/dist/assets/AssistantView-BTCkm8yk.js +3 -0
  8. package/dist/assets/{DiagramView-Bh06xinl.js → DiagramView-xuSJTnv7.js} +1 -1
  9. package/dist/assets/{FadeInOnReveal-CilRl4gc.js → FadeInOnReveal-Ov0D96d0.js} +1 -1
  10. package/dist/assets/{Icon-i5GiQRLq.js → Icon-DD-BuMfZ.js} +1 -1
  11. package/dist/assets/MarkdownRenderer-bc_p1H7l.js +2 -0
  12. package/dist/assets/{MarkdownRendererImpl-f2agCc4q.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-ChD4WD2v.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-D7SG0ueZ.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-CjCw1X4n.js → MultiRunWindow-ChZGob0v.js} +1 -1
  18. package/dist/assets/{OnboardingScreen-BGRLk4Wi.js → OnboardingScreen-DtQJxZG-.js} +1 -1
  19. package/dist/assets/{OverlayScrollbar-NuQI58XD.js → OverlayScrollbar-pMwxaCca.js} +1 -1
  20. package/dist/assets/SettingsView-B5I0KDTZ.js +25 -0
  21. package/dist/assets/{SettingsWindow-C2DgbXJ3.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-lubDPKMs.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-CzgW3-Cb.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-BKc-qXbQ.js → appThemeRegistry-COGfxTFV.js} +1 -1
  25. package/dist/assets/{button-DtzFDvij.js → button-D-rATN7w.js} +1 -1
  26. package/dist/assets/{collapsible-D-nzhcyF.js → collapsible-BXRd5_Hr.js} +1 -1
  27. package/dist/assets/{context-menu-BzVSzHLG.js → context-menu-jGtH6fR9.js} +1 -1
  28. package/dist/assets/{diagram-2YWalSb5.js → diagram-DQf7Y_uR.js} +1 -1
  29. package/dist/assets/{dialog-DGV7DsZC.js → dialog-bfbvR4VH.js} +1 -1
  30. package/dist/assets/es-c52LTHHe.js +15 -0
  31. package/dist/assets/{fileMentionTouchSelection-BTkP7aYI.js → fileMentionTouchSelection-BvrNCroG.js} +1 -1
  32. package/dist/assets/{fr-DsBZeN8Y.js → fr-PisFbUE8.js} +3 -3
  33. package/dist/assets/gitApi-ButwzQ0N.js +9 -0
  34. package/dist/assets/{input-CbB2HdLg.js → input-8r6F_Fza.js} +1 -1
  35. package/dist/assets/insertionBoundaries-DGs7S5zv.js +1 -0
  36. package/dist/assets/ja-C8Ikun3Z.js +15 -0
  37. package/dist/assets/ko-ClFys5o2.js +15 -0
  38. package/dist/assets/main-CCE_4y5F.js +2 -0
  39. package/dist/assets/main-Dn9rpsgl.js +169 -0
  40. package/dist/assets/{markdownHeightCache-C9ym9Tsb.js → markdownHeightCache-1Qo6y6IJ.js} +1 -1
  41. package/dist/assets/miniChat-BpjxVqgg.js +2 -0
  42. package/dist/assets/mobile-nMJCaNq1.js +2 -0
  43. package/dist/assets/multirun-BvSG94mW.js +4 -0
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/projectMeta-DOe49dM_.js +3 -0
  47. package/dist/assets/pt-BR-s8OpfRI6.js +15 -0
  48. package/dist/assets/renderElectronMiniChatApp-R6dKh79f.js +2 -0
  49. package/dist/assets/renderMobileApp-y-S8y8v_.js +28 -0
  50. package/dist/assets/{runtimeConfig-PpnZiwcW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BGjzNuQl.js → runtimeEndpointReset-B6BxhvR0.js} +6 -6
  52. package/dist/assets/sessionChangeSummary-VVBlEXuh.js +2 -0
  53. package/dist/assets/{sessionLookup-CbbzalcH.js → sessionLookup-gh-hVRIc.js} +1 -1
  54. package/dist/assets/{sortable-tabs-strip-CfgEJfB0.js → sortable-tabs-strip-CudoSgV4.js} +1 -1
  55. package/dist/assets/src-BzX5UI-c.css +2 -0
  56. package/dist/assets/{store-B6dImdlY.js → store-DJczmczv.js} +4 -4
  57. package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-DiF6JmBP.js} +1 -1
  58. package/dist/assets/textarea-3XR8sUfy.js +1 -0
  59. package/dist/assets/{tooltip-Djn7MSOX.js → tooltip-CQvpK-UV.js} +1 -1
  60. package/dist/assets/uk-CaX0iqkB.js +15 -0
  61. package/dist/assets/{useAppFontEffects-BJBcy-84.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/useDesktopWindowControlsLayout-B1GX59-L.js +1 -0
  63. package/dist/assets/{useEffectiveDirectory-CzHynoi_.js → useEffectiveDirectory-CN6JoCFw.js} +1 -1
  64. package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-DcbXQOkc.js} +1 -1
  65. package/dist/assets/useMobileNavigationStore-BVjFtXSy.js +414 -0
  66. package/dist/assets/{useSessionAutoCleanup-DzV9N6G-.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-Bi9ASKr_.js → useWorkerHighlightedLines-Bqn4AiBL.js} +1 -1
  68. package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
  69. package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
  70. package/dist/assets/zh-CN-20MYEjha.js +15 -0
  71. package/dist/assets/{zh-TW-BsOQbrwT.js → zh-TW-DiN04qTw.js} +3 -3
  72. package/dist/index.html +6 -6
  73. package/dist/mini-chat.html +6 -6
  74. package/dist/mobile.html +6 -6
  75. package/package.json +2 -2
  76. package/server/index.js +107 -0
  77. package/server/lib/assistants/DOCUMENTATION.md +18 -18
  78. package/server/lib/assistants/assign.js +2 -1
  79. package/server/lib/assistants/contact-store.js +37 -19
  80. package/server/lib/assistants/projection-history.test.js +207 -0
  81. package/server/lib/assistants/routes.js +5 -5
  82. package/server/lib/assistants/service.js +493 -123
  83. package/server/lib/assistants/service.test.js +203 -133
  84. package/server/lib/conversations/DOCUMENTATION.md +4 -4
  85. package/server/lib/conversations/service.js +86 -57
  86. package/server/lib/conversations/service.test.js +116 -100
  87. package/server/lib/event-stream/global-hub.js +2 -1
  88. package/server/lib/llm/DOCUMENTATION.md +71 -84
  89. package/server/lib/llm/catalog.js +111 -45
  90. package/server/lib/llm/catalog.test.js +121 -59
  91. package/server/lib/llm/completions.js +37 -12
  92. package/server/lib/llm/completions.test.js +45 -4
  93. package/server/lib/llm/generate.js +421 -450
  94. package/server/lib/llm/generate.test.js +553 -487
  95. package/server/lib/llm/routes.js +6 -4
  96. package/server/lib/message-queue/DOCUMENTATION.md +5 -5
  97. package/server/lib/message-queue/opencode-adapter.js +191 -104
  98. package/server/lib/message-queue/opencode-adapter.test.js +328 -58
  99. package/server/lib/message-queue/runtime.test.js +6 -6
  100. package/server/lib/message-queue/worker.js +7 -1
  101. package/server/lib/opencode/DOCUMENTATION.md +28 -5
  102. package/server/lib/opencode/config-entity-routes.js +84 -13
  103. package/server/lib/opencode/config-entity-routes.test.js +41 -10
  104. package/server/lib/opencode/ensure-cli.js +239 -0
  105. package/server/lib/opencode/ensure-cli.test.js +119 -0
  106. package/server/lib/opencode/env-runtime.js +193 -110
  107. package/server/lib/opencode/env-runtime.test.js +221 -17
  108. package/server/lib/opencode/feature-routes-runtime.js +37 -1
  109. package/server/lib/opencode/lifecycle.js +204 -70
  110. package/server/lib/opencode/lifecycle.test.js +376 -6
  111. package/server/lib/opencode/network-runtime.js +28 -16
  112. package/server/lib/opencode/network-runtime.test.js +68 -4
  113. package/server/lib/opencode/opencode2-pin.js +139 -0
  114. package/server/lib/opencode/opencode2-pin.test.js +87 -0
  115. package/server/lib/opencode/opencode2-sidecar.e2e.test.js +203 -0
  116. package/server/lib/opencode/plugin-compatibility.js +15 -0
  117. package/server/lib/opencode/plugin-compatibility.test.js +21 -0
  118. package/server/lib/opencode/plugins.js +4 -3
  119. package/server/lib/opencode/plugins.test.js +12 -0
  120. package/server/lib/opencode/proxy.js +153 -16
  121. package/server/lib/opencode/proxy.test.js +120 -0
  122. package/server/lib/opencode/routes.js +25 -32
  123. package/server/lib/opencode/routes.test.js +51 -52
  124. package/server/lib/opencode/server-utils-runtime.js +2 -0
  125. package/server/lib/opencode/skill-routes.js +6 -7
  126. package/server/lib/opencode/skill-routes.test.js +36 -5
  127. package/server/lib/opencode/v1-migration-gate.js +157 -0
  128. package/server/lib/opencode/v1-migration-gate.test.js +111 -0
  129. package/server/lib/opencode/v2-client.js +49 -0
  130. package/server/lib/opencode/watcher.js +2 -1
  131. package/server/lib/opencode/watcher.test.js +1 -1
  132. package/server/lib/scheduled-tasks/DOCUMENTATION.md +71 -39
  133. package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
  134. package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
  135. package/server/lib/scheduled-tasks/routes.js +27 -0
  136. package/server/lib/scheduled-tasks/routes.test.js +94 -0
  137. package/server/lib/scheduled-tasks/runtime.js +331 -308
  138. package/server/lib/scheduled-tasks/runtime.test.js +530 -318
  139. package/server/lib/session-goal/DOCUMENTATION.md +65 -14
  140. package/server/lib/session-goal/capability.js +57 -0
  141. package/server/lib/session-goal/capability.test.js +33 -0
  142. package/server/lib/session-goal/routes.js +231 -2
  143. package/server/lib/session-goal/routes.test.js +211 -0
  144. package/server/lib/session-goal/runtime.js +46 -4
  145. package/server/lib/session-goal/runtime.test.js +62 -1
  146. package/server/lib/session-index/sync-runtime.js +20 -17
  147. package/server/lib/session-index/sync-runtime.test.js +9 -8
  148. package/server/lib/session-metadata/DOCUMENTATION.md +41 -0
  149. package/server/lib/session-metadata/routes.js +94 -0
  150. package/server/lib/session-metadata/routes.test.js +111 -0
  151. package/server/lib/session-metadata/session-metadata-store.js +235 -0
  152. package/server/lib/session-metadata/session-metadata-store.test.js +184 -0
  153. package/server/lib/session-turn-pages/DOCUMENTATION.md +3 -3
  154. package/server/lib/session-turn-pages/reconcile.service.js +1 -1
  155. package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
  156. package/server/lib/session-turn-pages/routes.js +204 -71
  157. package/server/lib/session-turn-pages/routes.test.js +285 -0
  158. package/server/lib/session-turn-pages/service.js +6 -2
  159. package/server/lib/session-turn-pages/service.test.js +34 -0
  160. package/server/lib/small-model/DOCUMENTATION.md +5 -4
  161. package/server/lib/small-model/catalog.js +56 -6
  162. package/server/lib/small-model/catalog.test.js +47 -0
  163. package/server/lib/small-model/index.js +3 -3
  164. package/server/lib/small-model/opencode-session.js +50 -206
  165. package/server/lib/small-model/opencode-session.test.js +58 -88
  166. package/server/opencode-proxy.test.js +61 -12
  167. package/dist/assets/AssistantView-BtGYjSW5.js +0 -3
  168. package/dist/assets/MarkdownRenderer-AwDKOCvD.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-B6fGRYsg.js +0 -1
  170. package/dist/assets/MobileSurface-BY7eErHN.js +0 -2
  171. package/dist/assets/SettingsView-lcRi35Y3.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CcIuekQ3.js +0 -7
  174. package/dist/assets/insertionBoundaries-BocNNP9Q.js +0 -1
  175. package/dist/assets/ja-Bgr8-dOg.js +0 -15
  176. package/dist/assets/ko-B_Gxh-0l.js +0 -15
  177. package/dist/assets/main-CFLmpNC5.js +0 -169
  178. package/dist/assets/main-Ckj_IpUy.js +0 -2
  179. package/dist/assets/miniChat-Dq1CTKNs.js +0 -2
  180. package/dist/assets/mobile-DlJ0Yr7h.js +0 -2
  181. package/dist/assets/multirun-BGUKIJys.js +0 -4
  182. package/dist/assets/pl-DW2XJGC4.js +0 -15
  183. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  184. package/dist/assets/projectMeta-Bft2t6GF.js +0 -3
  185. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  186. package/dist/assets/renderElectronMiniChatApp-CTbXbB0Y.js +0 -2
  187. package/dist/assets/renderMobileApp-DLewUqoE.js +0 -28
  188. package/dist/assets/sessionChangeSummary-DiqwX3vZ.js +0 -2
  189. package/dist/assets/src-rS3zCcsC.css +0 -2
  190. package/dist/assets/textarea-DpK2Z0Gp.js +0 -1
  191. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  192. package/dist/assets/useDesktopWindowControlsLayout-3Bm0rXCQ.js +0 -1
  193. package/dist/assets/useMobileNavigationStore-BIyCYi_z.js +0 -413
  194. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  195. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -21,6 +21,7 @@ export const createServerUtilsRuntime = (dependencies) => {
21
21
  setOpenCodeNotReadySince,
22
22
  clearLastOpenCodeError,
23
23
  getLoginShellPath,
24
+ getStoredSessionMetadata = null,
24
25
  } = dependencies;
25
26
 
26
27
  const setOpenCodePort = (port) => {
@@ -215,6 +216,7 @@ export const createServerUtilsRuntime = (dependencies) => {
215
216
  getUiNotificationClients,
216
217
  onInteractiveSessionRequest: options.onInteractiveSessionRequest,
217
218
  onSessionTurnAdmission: options.onSessionTurnAdmission,
219
+ getStoredSessionMetadata,
218
220
  });
219
221
  };
220
222
 
@@ -1,4 +1,4 @@
1
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
1
+ import { OpenCode } from '@opencode/client';
2
2
 
3
3
  const summarizeDescription = (value) => {
4
4
  const normalized = typeof value === 'string' ? value.replace(/\s+/gu, ' ').trim() : '';
@@ -127,19 +127,18 @@ export const registerSkillRoutes = (app, dependencies) => {
127
127
  }
128
128
 
129
129
  try {
130
- const client = createOpencodeClient({
130
+ const client = OpenCode.make({
131
131
  baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
132
- directory: workingDirectory || undefined,
133
132
  headers: getOpenCodeAuthHeaders(),
134
133
  fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
135
134
  });
136
135
 
137
- const response = await client.app.skills(
138
- workingDirectory ? { directory: workingDirectory } : undefined,
136
+ const response = await client.skill.list(
137
+ workingDirectory ? { location: { directory: workingDirectory } } : undefined,
139
138
  );
140
139
  const payload = response?.data;
141
140
  if (!Array.isArray(payload)) {
142
- return [];
141
+ throw new Error('OpenCode skill catalog is unavailable');
143
142
  }
144
143
 
145
144
  return payload
@@ -177,7 +176,7 @@ export const registerSkillRoutes = (app, dependencies) => {
177
176
  .filter(Boolean);
178
177
  } catch (error) {
179
178
  console.error('Failed to list OpenCode skills:', error);
180
- return [];
179
+ throw error;
181
180
  }
182
181
  };
183
182
 
@@ -2,16 +2,16 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import express from 'express';
3
3
  import request from 'supertest';
4
4
 
5
- vi.mock('@opencode-ai/sdk/v2', () => ({ createOpencodeClient: vi.fn() }));
5
+ vi.mock('@opencode/client', () => ({ OpenCode: { make: vi.fn() } }));
6
6
 
7
- const { createOpencodeClient } = await import('@opencode-ai/sdk/v2');
7
+ const { OpenCode } = await import('@opencode/client');
8
8
  const { registerSkillRoutes } = await import('./skill-routes.js');
9
9
 
10
10
  describe('skill summary route', () => {
11
11
  it('returns compact normalized skill data without content or sources', async () => {
12
- createOpencodeClient.mockReturnValue({
13
- app: {
14
- skills: vi.fn(async () => ({ data: [{
12
+ OpenCode.make.mockReturnValue({
13
+ skill: {
14
+ list: vi.fn(async () => ({ data: [{
15
15
  name: 'skill',
16
16
  location: '/repo/.opencode/skills/skill/SKILL.md',
17
17
  description: ` ${'😀'.repeat(161)}\nnext `,
@@ -47,5 +47,36 @@ describe('skill summary route', () => {
47
47
  });
48
48
  expect(response.body.skills[0]).not.toHaveProperty('content');
49
49
  expect(response.body.skills[0]).not.toHaveProperty('sources');
50
+ expect(OpenCode.make).toHaveBeenCalledWith(expect.objectContaining({
51
+ baseUrl: 'http://opencode-upstream:4096',
52
+ headers: { Authorization: 'Basic example' },
53
+ }));
54
+ expect(OpenCode.make.mock.calls[0][0].directory).toBeUndefined();
55
+ });
56
+
57
+ it('returns 500 when OpenCode skill list fails instead of an empty success', async () => {
58
+ OpenCode.make.mockReturnValue({
59
+ skill: {
60
+ list: vi.fn(async () => { throw new Error('upstream sentinel'); }),
61
+ },
62
+ });
63
+ const app = express();
64
+ registerSkillRoutes(app, {
65
+ fs: { existsSync: () => false },
66
+ path: await import('node:path'),
67
+ os: await import('node:os'),
68
+ resolveProjectDirectory: async () => ({ directory: '/repo' }),
69
+ resolveOptionalProjectDirectory: async () => ({ directory: '/repo' }),
70
+ buildOpenCodeUrl: () => 'http://opencode-upstream:4096/',
71
+ getOpenCodeAuthHeaders: () => ({ Authorization: 'Basic example' }),
72
+ getOpenCodePort: () => 4096,
73
+ SKILL_SCOPE: { PROJECT: 'project', USER: 'user' },
74
+ discoverSkills: () => [],
75
+ mergeDiscoveredSkills: (primary) => primary,
76
+ });
77
+
78
+ const response = await request(app).get('/api/config/skills?summary=true&directory=%2Frepo').expect(500);
79
+ expect(JSON.stringify(response.body)).not.toContain('sentinel');
80
+ expect(response.body).toEqual({ error: 'Failed to list skills' });
50
81
  });
51
82
  });
@@ -0,0 +1,157 @@
1
+ export const OPENCODE_V1_MIGRATION_PATH = '/api/experimental/migration/v1';
2
+
3
+ // Backfill notes must stay readable by UI and docs. Do not drop them when the
4
+ // gate blocks or later admits a migrated library.
5
+ export const V1_MIGRATION_USER_NOTICE = [
6
+ 'V1 history is backfilled in the opencode2 process.',
7
+ 'Message ids are reused.',
8
+ 'In-progress tools become interrupted.',
9
+ 'V1 subtasks do not appear in v2.',
10
+ ].join(' ');
11
+
12
+ const MIGRATION_PHASES = new Set(['required', 'running', 'completed', 'error']);
13
+
14
+ const stringifyError = (error) => {
15
+ if (error instanceof Error && error.message) {
16
+ return error.message;
17
+ }
18
+ if (typeof error === 'string' && error.trim()) {
19
+ return error;
20
+ }
21
+ return 'V1 migration status request failed';
22
+ };
23
+
24
+ const blocked = (phase, extra = {}) => ({
25
+ admitTranscript: false,
26
+ phase,
27
+ userNotice: V1_MIGRATION_USER_NOTICE,
28
+ ...extra,
29
+ });
30
+
31
+ const admitted = (phase, extra = {}) => ({
32
+ admitTranscript: true,
33
+ phase,
34
+ ...extra,
35
+ });
36
+
37
+ const normalizeProgress = (progress) => {
38
+ if (!progress || typeof progress !== 'object' || Array.isArray(progress)) {
39
+ return undefined;
40
+ }
41
+ const result = {
42
+ label: typeof progress.label === 'string' ? progress.label : '',
43
+ };
44
+ if (Number.isFinite(progress.numerator) && progress.numerator >= 0) {
45
+ result.numerator = progress.numerator;
46
+ }
47
+ if (Number.isFinite(progress.denominator) && progress.denominator >= 0) {
48
+ result.denominator = progress.denominator;
49
+ }
50
+ return result;
51
+ };
52
+
53
+ const resolveHttpStatus = (input) => {
54
+ if (typeof input.httpStatus === 'number') {
55
+ return input.httpStatus;
56
+ }
57
+ if (typeof input.status === 'number') {
58
+ return input.status;
59
+ }
60
+ return undefined;
61
+ };
62
+
63
+ const resolveBody = (input) => {
64
+ if (input.body !== undefined) {
65
+ return input.body;
66
+ }
67
+ if (typeof input.status === 'string') {
68
+ return input;
69
+ }
70
+ return null;
71
+ };
72
+
73
+ const resolveTransportError = (input) => {
74
+ if (input instanceof Error || typeof input === 'string') {
75
+ return input;
76
+ }
77
+ if (input?.error instanceof Error || typeof input?.error === 'string') {
78
+ if (input.body === undefined && typeof input.status !== 'string') {
79
+ return input.error;
80
+ }
81
+ }
82
+ return null;
83
+ };
84
+
85
+ // Pure admission decision for GET /api/experimental/migration/v1.
86
+ // required/running/error never become an empty-list success.
87
+ export const evaluateV1MigrationGate = (input = {}) => {
88
+ if (input == null) {
89
+ return blocked('error', { error: 'V1 migration status is missing or invalid' });
90
+ }
91
+
92
+ const transportError = resolveTransportError(input);
93
+ if (transportError) {
94
+ return blocked('error', { error: stringifyError(transportError) });
95
+ }
96
+
97
+ const httpStatus = resolveHttpStatus(input);
98
+ if (httpStatus === 404) {
99
+ return admitted('absent');
100
+ }
101
+ if (Number.isInteger(httpStatus) && httpStatus >= 400) {
102
+ return blocked('error', { error: `V1 migration status HTTP ${httpStatus}` });
103
+ }
104
+
105
+ const body = resolveBody(input);
106
+ const phase = body && typeof body === 'object' ? body.status : undefined;
107
+
108
+ if (phase === 'required') {
109
+ return blocked('required');
110
+ }
111
+ if (phase === 'running') {
112
+ return blocked('running', { progress: normalizeProgress(body.progress) });
113
+ }
114
+ if (phase === 'completed') {
115
+ return admitted('completed', { userNotice: V1_MIGRATION_USER_NOTICE });
116
+ }
117
+ if (phase === 'error') {
118
+ return blocked('error', {
119
+ error: typeof body.error === 'string' && body.error.trim()
120
+ ? body.error
121
+ : 'V1 migration failed',
122
+ });
123
+ }
124
+
125
+ if (MIGRATION_PHASES.has(phase) === false) {
126
+ return blocked('error', { error: 'V1 migration status is missing or invalid' });
127
+ }
128
+
129
+ return blocked('error', { error: 'V1 migration status is missing or invalid' });
130
+ };
131
+
132
+ // Official protocol only publishes GET status. Backfill is owned by opencode2;
133
+ // clients poll this helper and do not POST unless a later API requires it.
134
+ export const fetchV1MigrationGate = async ({
135
+ url,
136
+ headers = {},
137
+ signal,
138
+ fetchImpl = globalThis.fetch,
139
+ } = {}) => {
140
+ try {
141
+ const response = await fetchImpl(url, {
142
+ method: 'GET',
143
+ headers: {
144
+ Accept: 'application/json',
145
+ ...headers,
146
+ },
147
+ signal,
148
+ });
149
+ const body = await response.json().catch(() => null);
150
+ return evaluateV1MigrationGate({
151
+ httpStatus: response.status,
152
+ body,
153
+ });
154
+ } catch (error) {
155
+ return evaluateV1MigrationGate({ error });
156
+ }
157
+ };
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ OPENCODE_V1_MIGRATION_PATH,
5
+ V1_MIGRATION_USER_NOTICE,
6
+ evaluateV1MigrationGate,
7
+ fetchV1MigrationGate,
8
+ } from './v1-migration-gate.js';
9
+
10
+ describe('V1 migration gate', () => {
11
+ it('denies transcript while V1 migration is required', () => {
12
+ expect(evaluateV1MigrationGate({ status: 'required' })).toMatchObject({
13
+ admitTranscript: false,
14
+ phase: 'required',
15
+ userNotice: V1_MIGRATION_USER_NOTICE,
16
+ });
17
+ });
18
+
19
+ it('denies transcript while V1 migration is running and keeps progress', () => {
20
+ expect(evaluateV1MigrationGate({
21
+ status: 'running',
22
+ progress: { label: 'Sessions', numerator: 3, denominator: 10 },
23
+ })).toEqual({
24
+ admitTranscript: false,
25
+ phase: 'running',
26
+ progress: { label: 'Sessions', numerator: 3, denominator: 10 },
27
+ userNotice: V1_MIGRATION_USER_NOTICE,
28
+ });
29
+ });
30
+
31
+ it('admits transcript when V1 migration is completed', () => {
32
+ expect(evaluateV1MigrationGate({ status: 'completed' })).toMatchObject({
33
+ admitTranscript: true,
34
+ phase: 'completed',
35
+ });
36
+ });
37
+
38
+ it('admits transcript when V1 library is absent (HTTP 404)', () => {
39
+ expect(evaluateV1MigrationGate({ httpStatus: 404 })).toEqual({
40
+ admitTranscript: true,
41
+ phase: 'absent',
42
+ });
43
+ expect(evaluateV1MigrationGate({ status: 404 })).toEqual({
44
+ admitTranscript: true,
45
+ phase: 'absent',
46
+ });
47
+ });
48
+
49
+ it('admits transcript when completed with no remaining work', () => {
50
+ expect(evaluateV1MigrationGate({
51
+ body: { status: 'completed' },
52
+ httpStatus: 200,
53
+ })).toMatchObject({
54
+ admitTranscript: true,
55
+ phase: 'completed',
56
+ });
57
+ });
58
+
59
+ it('denies transcript on V1 migration error instead of treating an empty list as success', () => {
60
+ expect(evaluateV1MigrationGate({
61
+ status: 'error',
62
+ error: 'disk full',
63
+ })).toEqual({
64
+ admitTranscript: false,
65
+ phase: 'error',
66
+ error: 'disk full',
67
+ userNotice: V1_MIGRATION_USER_NOTICE,
68
+ });
69
+ expect(evaluateV1MigrationGate({})).toMatchObject({
70
+ admitTranscript: false,
71
+ phase: 'error',
72
+ });
73
+ });
74
+
75
+ it('denies transcript on network failure instead of treating an empty list as success', () => {
76
+ expect(evaluateV1MigrationGate({ error: new Error('offline') })).toMatchObject({
77
+ admitTranscript: false,
78
+ phase: 'error',
79
+ error: 'offline',
80
+ });
81
+ });
82
+
83
+ it('exposes backfill userNotice for UI and docs', () => {
84
+ expect(V1_MIGRATION_USER_NOTICE).toContain('Message ids are reused');
85
+ expect(V1_MIGRATION_USER_NOTICE).toContain('interrupted');
86
+ expect(V1_MIGRATION_USER_NOTICE).toContain('V1 subtasks do not appear in v2');
87
+ expect(evaluateV1MigrationGate({ status: 'required' }).userNotice).toBe(V1_MIGRATION_USER_NOTICE);
88
+ expect(evaluateV1MigrationGate({ status: 'completed' }).userNotice).toBe(V1_MIGRATION_USER_NOTICE);
89
+ });
90
+
91
+ it('polls GET migration status and does not POST', async () => {
92
+ const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ status: 'completed' }), {
93
+ status: 200,
94
+ headers: { 'content-type': 'application/json' },
95
+ }));
96
+
97
+ await expect(fetchV1MigrationGate({
98
+ url: `http://127.0.0.1:4096${OPENCODE_V1_MIGRATION_PATH}`,
99
+ headers: { Authorization: 'Basic test' },
100
+ fetchImpl,
101
+ })).resolves.toMatchObject({
102
+ admitTranscript: true,
103
+ phase: 'completed',
104
+ });
105
+
106
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
107
+ expect(fetchImpl.mock.calls[0][0]).toContain(OPENCODE_V1_MIGRATION_PATH);
108
+ expect(fetchImpl.mock.calls[0][1].method).toBe('GET');
109
+ expect(fetchImpl.mock.calls[0][1].headers.Authorization).toBe('Basic test');
110
+ });
111
+ });
@@ -0,0 +1,49 @@
1
+ import { OpenCode } from '@opencode/client';
2
+
3
+ /**
4
+ * @typedef {import('@opencode/client').OpenCodeClient} OpenCodeV2ClientRaw
5
+ */
6
+
7
+ /**
8
+ * Compatibility client: official 2.x nests exact message lookup under
9
+ * `session.message.get`, while OpenChamber callers still use
10
+ * `session.message(input, requestOptions)`.
11
+ *
12
+ * @typedef {Omit<OpenCodeV2ClientRaw, 'session'> & {
13
+ * session: Omit<OpenCodeV2ClientRaw['session'], 'message'> & {
14
+ * message: ((input: Parameters<OpenCodeV2ClientRaw['session']['message']['get']>[0], requestOptions?: Parameters<OpenCodeV2ClientRaw['session']['message']['get']>[1]) => ReturnType<OpenCodeV2ClientRaw['session']['message']['get']>)
15
+ * & OpenCodeV2ClientRaw['session']['message']
16
+ * }
17
+ * }} OpenCodeV2Client
18
+ */
19
+
20
+ /**
21
+ * Create the single server-side client for official OpenCode v2 APIs.
22
+ *
23
+ * @param {{ baseUrl: string, authHeaders?: Record<string, string>, fetchImpl?: typeof fetch }} input
24
+ * @returns {OpenCodeV2Client}
25
+ */
26
+ export function makeOpenCodeV2Client({ baseUrl, authHeaders, fetchImpl }) {
27
+ const client = OpenCode.make({
28
+ baseUrl: baseUrl.replace(/\/$/, ''),
29
+ ...(authHeaders ? { headers: authHeaders } : {}),
30
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
31
+ });
32
+
33
+ const messageGet = client.session.message?.get?.bind(client.session.message);
34
+ if (typeof messageGet === 'function') {
35
+ const messageCompat = Object.assign(
36
+ (input, requestOptions) => messageGet(input, requestOptions),
37
+ client.session.message,
38
+ );
39
+ return {
40
+ ...client,
41
+ session: {
42
+ ...client.session,
43
+ message: messageCompat,
44
+ },
45
+ };
46
+ }
47
+
48
+ return client;
49
+ }
@@ -65,7 +65,8 @@ export const createOpenCodeWatcherRuntime = (deps) => {
65
65
 
66
66
  reader = createUpstreamSseReader({
67
67
  signal,
68
- buildUrl: () => buildOpenCodeUrl('/global/event', ''),
68
+ // v2 serves the event stream at `/api/event`; `/api/global/event` is gone.
69
+ buildUrl: () => buildOpenCodeUrl('/event', ''),
69
70
  getHeaders: getOpenCodeAuthHeaders,
70
71
  fetchImpl,
71
72
  stallTimeoutMs: upstreamStallTimeoutMs,
@@ -74,7 +74,7 @@ describe('createOpenCodeWatcherRuntime', () => {
74
74
 
75
75
  expect(fetchCalls).toEqual([
76
76
  {
77
- url: 'http://127.0.0.1:4096/global/event',
77
+ url: 'http://127.0.0.1:4096/event',
78
78
  headers: {
79
79
  Accept: 'text/event-stream',
80
80
  'Cache-Control': 'no-cache',
@@ -15,7 +15,7 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
15
15
  - Next-run computation (daily/weekly/cron compatibility)
16
16
  - Timer scheduling and queueing
17
17
  - Concurrency controls
18
- - Session create → archive → goal/prompt/command execution
18
+ - Session create → goal/prompt/command execution
19
19
  - Emits OpenChamber task-run events
20
20
  - Isolates project sync failures, retries each failed project up to three times,
21
21
  and clears pending retries during shutdown
@@ -186,44 +186,55 @@ Every actual run (timer or manual):
186
186
  4. Create an OpenCode session with:
187
187
  - title: `[Scheduled] ${taskName} yyyy-LL-dd HH:mm` (total length ≤ 120)
188
188
  - metadata: `openchamber.scheduledTask = { projectID, taskID, runID, name }`
189
- - SDK/HTTP calls receive `{ signal }` as the second request-options argument
190
- (`client.session.create(params, { signal })`, same for `update` /
191
- `command` / `command.list`; raw `fetch` for goal PATCH and `prompt_async`
192
- pass `signal` in `RequestInit`).
193
- 5. `attachSession(runID, sessionID)` immediately after create.
194
- 6. Archive the session via SDK `client.session.update({ sessionID, directory, time: { archived } })`.
195
- - 404 receives one short bounded retry (also signal-aware); other failures
196
- abort without prompt.
197
- - If attach fails, the runtime still attempts archive, then fails the run.
198
- 7. Only after a successful archive: goal metadata PATCH (preserving the
199
- `scheduledTask` marker together with `goal`), then command or `prompt_async`.
200
- Non-cancellable async gaps (small-model distill, objective file write) check
201
- `signal.throwIfAborted()` before continuing so a timed-out run never prompts.
202
- **Admission is not completion:** `prompt_async` / command return when the
189
+ - SDK/HTTP calls receive `{ signal }` as the second request-options argument
190
+ (`client.session.create(params, { signal })`, same for `prompt` /
191
+ `command` / `command.list`; raw `fetch` for goal PATCH
192
+ pass `signal` in `RequestInit`).
193
+ 5. `attachSession(runID, sessionID)` immediately after create.
194
+ - If attach fails, the runtime fails the run without prompting.
195
+ 6. When `goalEnabled`: refuse before OpenCode ready / session create if
196
+ `persistSessionGoal` is not injected; otherwise write objective file,
197
+ persist an active goal into the Host session-metadata store, optionally
198
+ notify `onGoalPersisted` to arm the goal loop, then command or
199
+ `session.prompt`. Non-cancellable async gaps (small-model distill,
200
+ objective file write) check `signal.throwIfAborted()` before continuing so
201
+ a timed-out run never prompts.
202
+ **Admission is not completion:** `session.prompt` / command return when the
203
203
  turn is accepted, not when the agent finishes.
204
- 8. Wait for the real session outcome (bounded by the same watchdog):
205
- - Poll `session.status` + `session.messages` until the session is idle and
206
- the tail is a settled assistant turn (completed, or incomplete-but-stable
207
- while idle). Assistant `error` (including abort) finalizes as `error`.
208
- - Goal-enabled runs poll `session.get` for terminal goal status
209
- (`complete` → success; `blocked` / `budgetLimited` → error) and do not
210
- finish on the first idle between goal turns.
211
- - `durationMs` / `finishedAt` are wall-clock from run start through this
212
- settlement — not the prompt admission latency.
213
- 9. On watchdog timeout after a session exists: immediately throw the canonical
204
+ 7. Wait for the real session outcome (bounded by the same watchdog):
205
+ - Poll `session.active` + `message.list` until the session is idle and
206
+ the **latest-side** message page settles. Settlement always uses
207
+ `message.list({ limit: 50, order: 'desc' })` (newest first) — never
208
+ `order: 'asc'` page-0 + `at(-1)`, which reads an old page once the
209
+ session exceeds the limit.
210
+ - Real terminal success requires an assistant tail with `time.completed`
211
+ and finish not `tool-calls` (prefer `finish: 'stop'`). `finish:
212
+ 'tool-calls'`, incomplete assistants (no `time.completed`), and
213
+ non-assistant tails such as `model-switched` never succeed — including
214
+ after multiple incomplete idle probes.
215
+ - `session.active` / `message.list` failures stay **unknown** (keep
216
+ polling under the watchdog); they never masquerade as idle success.
217
+ - Assistant `error` (and terminal finish `error` / `length` /
218
+ `content-filter`) finalizes as `error`.
219
+ - Goal-enabled runs: settle via Host goal terminal status when the store
220
+ seam is available (`readSessionMetadata` preferred over OpenCode
221
+ `session.get`). Store read failure is never empty-goal success.
222
+ - `durationMs` / `finishedAt` are wall-clock from run start through this
223
+ settlement — not the prompt admission latency.
224
+ 8. On watchdog timeout after a session exists: immediately throw the canonical
214
225
  `schedule run timed out` without awaiting non-cancellable helper work. A
215
226
  once-only abort listener registered after session create starts best-effort
216
- `client.session.abort({ sessionID, directory })` as soon as the watchdog
217
- aborts the signal (or right after create if the signal already aborted).
218
- Abort failures must not replace the timeout error. Successful runs and
219
- ordinary non-timeout failures never call session abort.
220
- 10. Finalize history with the ultimate run status (timeout → `error` with
227
+ `client.session.interrupt({ sessionID, continue: false })` as soon as the watchdog
228
+ aborts the signal (or right after create if the signal already aborted).
229
+ Interrupt failures must not replace the timeout error. Successful runs and
230
+ ordinary non-timeout failures never call session interrupt.
231
+ 9. Finalize history with the ultimate run status (timeout → `error` with
221
232
  `schedule run timed out`; assistant/goal failure → `error` with the
222
233
  recorded reason). An already-attached `session_id` remains on the history
223
234
  row so the session stays openable from history. Task state persistence
224
235
  failures also finalize history as `error`. A failed history finalize
225
236
  returns error and may leave a `running` row for next-start convergence.
226
- 11. `lastSessionId` is written on the task state whenever the run has a
237
+ 10. `lastSessionId` is written on the task state whenever the run has a
227
238
  session (success or error). If the run has no session, omit
228
239
  `lastSessionId` from the patch so a previous value is preserved. Full
229
240
  association still lives in the history table; history rows are not
@@ -239,14 +250,35 @@ that same history session. `observeSessionEvent` corrects **only task state**
239
250
 
240
251
  - `session.status` busy/retry while `lastStatus` is `error` or `success` →
241
252
  `lastStatus` `running`, clear `lastError`, keep/set `lastSessionId`
242
- - later `session.idle` (or idle `session.status`) with a success snapshot →
243
- `lastStatus` `success`
253
+ - later `session.idle` (or idle `session.status`) with a success snapshot from
254
+ `session.active` + latest-side `message.list` → `lastStatus` `success`
244
255
  - do not change `lastRunAt` / `lastDurationMs` / `nextRunAt`
256
+ - history rows are never rewritten; `finishRun` is not called again
245
257
 
246
258
  A live run (`runningTaskKeys`) owns settlement; the observer is a no-op while
247
259
  the task is in `runningTaskKeys`. Idle correction is a no-op if `lastStatus`
248
- is already `success`, or if the snapshot is still error/busy/unknown. Observer
249
- failures are logged and must not throw out of the event bus.
260
+ is already `success`, or if the snapshot is still error/busy/unknown
261
+ (`session.active` failure and goal-unsupported goal tasks stay unknown).
262
+ Observer failures are logged and must not throw out of the event bus.
263
+
264
+ ## Goal safety gating
265
+
266
+ Session goal Host state is advertised by
267
+ `packages/web/server/lib/session-goal/capability.js` as `{ supported: true }`.
268
+ Goal records live in the OpenChamber session-metadata store (same seam as
269
+ manual UI / `session-goal` runtime). Scheduled runs inject
270
+ `persistSessionGoal`, `readSessionMetadata`, and optional `onGoalPersisted`
271
+ from `server/index.js`.
272
+
273
+ | Path | Behavior |
274
+ |---|---|
275
+ | `PUT` scheduled task / managed-tool create·update with `execution.goalEnabled` | Allowed when capability supported |
276
+ | Existing `goalEnabled` task due or manual run | Requires `persistSessionGoal`; without it fail **before** OpenCode ready / session create / objective write / prompt; with it write objective + active goal then prompt |
277
+ | Ordinary non-goal task | Unchanged |
278
+ | Post-run observer on a goal-enabled task | Prefer Host store goal terminal status; never invent success from assistant-tail idle alone when goal is active |
279
+
280
+ Do not treat objective-file hints or first-turn assistant idle as a registered
281
+ goal success without a store-backed goal record.
250
282
 
251
283
  Run start also emits `scheduled-task-ran` with `status: running` as soon as
252
284
  task state is persisted, so the outer task list can show in-progress before
@@ -255,21 +287,21 @@ the session is attached.
255
287
  ## Watchdog cancel / upstream abort
256
288
 
257
289
  - Timeout path: abort the per-run signal → cancel in-flight cancellable
258
- SDK/HTTP → start best-effort OpenCode `session.abort` via the create-time
290
+ SDK/HTTP → start best-effort OpenCode `session.interrupt` via the create-time
259
291
  abort listener (independent of whether the current await is cancellable) →
260
292
  **do not wait** for non-cancellable helpers (small-model distill /
261
293
  `writeObjective`) to finish → history and task state finalize as `error`
262
294
  with `schedule run timed out` within the watchdog bound. The detached
263
295
  `runPromise` is settled with a catch so late rejections are not unhandled.
264
296
  - When uncancellable helper work eventually finishes, post-await
265
- `throwIfAborted` gates stop goal PATCH / command / `prompt_async`.
266
- - Non-timeout failures: no timeout abort; no forced `session.abort` from the
297
+ `throwIfAborted` gates stop goal PATCH / command / `session.prompt`.
298
+ - Non-timeout failures: no timeout abort; no forced `session.interrupt` from the
267
299
  watchdog.
268
300
  - Runtime `stop()` clears timers, project-sync retries, and the queue only; it
269
301
  does not abort in-flight run controllers.
270
302
  - Remaining non-cancellable edges: work already fully admitted and completed
271
303
  HTTP-wise before timeout cannot be rolled back by aborting the HTTP request
272
- alone; `session.abort` is the upstream stop for admitted command/prompt
304
+ alone; `session.interrupt` is the upstream stop for admitted command/prompt
273
305
  execution. Small-model distillation and objective file writes are not passed
274
306
  a signal; the watchdog does not await them, and abort gates block later
275
307
  stages after they complete.