@openchambery/web 1.19.20 → 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 (189) 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-C_JfbxNl.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-B1iUwzOz.js → MarkdownRendererImpl-SKi4oG0c.js} +1 -1
  13. package/dist/assets/{MarkstreamRendererImpl-BPtMj3-X.js → MarkstreamRendererImpl-CnkodGJW.js} +1 -1
  14. package/dist/assets/MobileDetailNavigation-Bnx59U_J.js +1 -0
  15. package/dist/assets/{MobileShareBridge-CKClHsWN.js → MobileShareBridge-BQlP4J-F.js} +1 -1
  16. package/dist/assets/MobileSurface-BXLeiZ4d.js +2 -0
  17. package/dist/assets/{MultiRunWindow-DiA24bnN.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-XlzRSS7E.js → SettingsWindow-DfnsFa8y.js} +1 -1
  22. package/dist/assets/{TerminalView-DdIaaLba.js → TerminalView-Dh9MWCpS.js} +1 -1
  23. package/dist/assets/{ToolOutputDialog-BbwuLdXU.js → ToolOutputDialog-BejmjrRk.js} +1 -1
  24. package/dist/assets/{appThemeRegistry-ChzfD7mc.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-DyjmxUEN.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-8fkF0Ji_.js → multirun-BvSG94mW.js} +1 -1
  44. package/dist/assets/pl-CnaKWN5F.js +15 -0
  45. package/dist/assets/platform-CWTIp02q.js +4 -0
  46. package/dist/assets/{projectMeta--y6tyjrv.js → projectMeta-DOe49dM_.js} +2 -2
  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-DYWtBrkW.js → runtimeConfig-BMvqa5we.js} +1 -1
  51. package/dist/assets/{runtimeEndpointReset-BSWg99jc.js → runtimeEndpointReset-B6BxhvR0.js} +5 -5
  52. package/dist/assets/{sessionChangeSummary-BwupJF-Q.js → sessionChangeSummary-VVBlEXuh.js} +1 -1
  53. package/dist/assets/{sessionLookup-DP2X1qRR.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-DZ60kHBP.css → src-BzX5UI-c.css} +1 -1
  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-BnD799LW.js → textarea-3XR8sUfy.js} +1 -1
  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-DWLxpWlS.js → useAppFontEffects-Cpmpo7Wb.js} +45 -45
  62. package/dist/assets/{useDesktopWindowControlsLayout-BGPR_rq6.js → useDesktopWindowControlsLayout-B1GX59-L.js} +1 -1
  63. package/dist/assets/{useEffectiveDirectory-CbV9_r1s.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-Dnors7hB.js → useSessionAutoCleanup-BLjymaFt.js} +1 -1
  67. package/dist/assets/{useWorkerHighlightedLines-DQqqrOBU.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-BjvfkSRS.js +0 -3
  168. package/dist/assets/MarkdownRenderer-Cg82L3fN.js +0 -2
  169. package/dist/assets/MobileDetailNavigation-s_vvUD_F.js +0 -1
  170. package/dist/assets/MobileSurface-DHpXHbJp.js +0 -2
  171. package/dist/assets/SettingsView-l0J7pfYo.js +0 -25
  172. package/dist/assets/es-rqMiRP7-.js +0 -15
  173. package/dist/assets/gitApi-CHpibtno.js +0 -7
  174. package/dist/assets/insertionBoundaries-Yd1Lorjw.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-BoRXxmB3.js +0 -2
  178. package/dist/assets/main-DCDcdV5o.js +0 -169
  179. package/dist/assets/miniChat-12fxk5KR.js +0 -2
  180. package/dist/assets/mobile-CkZem9Ad.js +0 -2
  181. package/dist/assets/pl-DW2XJGC4.js +0 -15
  182. package/dist/assets/platform-0w_Q9_ji.js +0 -2
  183. package/dist/assets/pt-BR-CIsanf4T.js +0 -15
  184. package/dist/assets/renderElectronMiniChatApp-COFc0ZDJ.js +0 -2
  185. package/dist/assets/renderMobileApp-BKzx7JL3.js +0 -28
  186. package/dist/assets/uk-BPmPEUYJ.js +0 -15
  187. package/dist/assets/useMobileNavigationStore-DrB_awgk.js +0 -413
  188. package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
  189. package/dist/assets/zh-CN-R8-mDcIp.js +0 -15
@@ -1,10 +1,34 @@
1
1
  # Session Goal
2
2
 
3
3
  Server-side control loop that keeps a session working toward a user-defined
4
- objective stored under `metadata.openchamber.goal`, with the small model as
5
- an independent progress auditor. Built on OpenChamber's backend-driven
6
- architecture: the loop lives in
7
- the web server and survives UI disconnects.
4
+ objective. On OpenCode 2.x the goal record lives in the OpenChamber session
5
+ metadata store (`packages/web/server/lib/session-metadata/`), folded back onto
6
+ `session.metadata` by the proxy. Capability is **open** (`supported: true`):
7
+ manual UI, goal routes, and scheduled-task goal create all write the same
8
+ store. The small model is an independent progress auditor. Built on
9
+ OpenChamber's backend-driven architecture: the loop lives in the web server
10
+ and survives UI disconnects.
11
+
12
+ ## v2 storage (OpenChamber session metadata store)
13
+
14
+ OpenCode 2.x accepts session `metadata` only at create time — there is no
15
+ PATCH. Goal progress therefore belongs in the Host
16
+ `packages/web/server/lib/session-metadata/` store (merge-patched under
17
+ `openchamber.goal`), not an OpenCode `PATCH /session/:id`.
18
+
19
+ `createSessionGoalRuntime` accepts optional seams `readSessionMetadata` and
20
+ `persistSessionGoal`. When **both** are injected, `writeGoal` / goal reads use
21
+ only the store and never PATCH OpenCode. When either is missing, the legacy
22
+ OpenCode PATCH path remains (tests and unwired servers).
23
+
24
+ Those seams, the proxy overlay, and `PUT /api/openchamber/sessions/:id/metadata`
25
+ are wired in `server/index.js`. A UI metadata write notifies the runtime with
26
+ a synthetic `session.updated` so create/resume can arm the loop.
27
+
28
+ **Capability is open** (`supported === true`). Scheduled-task goal create and
29
+ `POST /api/goals/:sessionId` persist an active goal into the same store as
30
+ manual UI metadata writes; after first persist they notify the runtime with a
31
+ synthetic `session.updated` so the loop can arm.
8
32
 
9
33
  ## Goal payload (`metadata.openchamber.goal`)
10
34
 
@@ -53,11 +77,16 @@ before touching the filesystem). Rationale: metadata rides every
53
77
  `session.updated`, so multi-KB objectives must not live there.
54
78
 
55
79
  - `objectives.js` — write/read/delete, 5000-char clamp.
56
- - `routes.js` — `PUT/GET/DELETE /api/goals/objective/:sessionId`
57
- (OpenChamber-owned, registered before the generic proxy; JSON parsing via
58
- the `/api/goals` family in core-routes). The UI writes the file BEFORE
59
- patching the goal metadata and falls back to an inline objective when the
60
- write fails; `clearSessionGoal` deletes the file best-effort.
80
+ - `capability.js` — `getSessionGoalCapability()` / `assertSessionGoalSupported()`
81
+ (single source; currently `{ supported: true }`).
82
+ - `routes.js` — `GET /api/goals/capability`, create/resume endpoints,
83
+ `PUT/GET/DELETE /api/goals/objective/:sessionId` (OpenChamber-owned,
84
+ registered before the generic proxy; JSON parsing via the `/api/goals`
85
+ family in core-routes). Create / resume require an injected
86
+ `persistSessionGoal` (503 when missing); objective PUT writes the file when
87
+ supported. The UI writes the file BEFORE patching the goal metadata and
88
+ falls back to an inline objective when the write fails;
89
+ `clearSessionGoal` deletes the file best-effort.
61
90
  - The tick resolves the effective objective fresh on every cycle (the file
62
91
  is live-editable mid-goal) and falls back to the inline `objective` when
63
92
  the file is unreadable — a goal never dies because a file went away.
@@ -183,13 +212,35 @@ sees only that final turn, so the report is its evidence.
183
212
  (edit/pause/resume/complete/clear).
184
213
  - Sidebar glyph next to the date in `SessionNodeItem`.
185
214
 
215
+ ## Capability (Host goal state)
216
+
217
+ `capability.js` is the single source for whether Host goal state is available:
218
+
219
+ ```
220
+ getSessionGoalCapability() → { supported: true }
221
+ ```
222
+
223
+ Goal records live in the OpenChamber session-metadata store (not OpenCode
224
+ SessionInfo). Routes, scheduled tasks, and the proxy overlay share that store.
225
+
226
+ | Surface | While supported |
227
+ |---|---|
228
+ | `GET /api/goals/capability` | `{ supported: true }` |
229
+ | `POST /api/goals/:sessionId` (create) | Write objective + persist active goal (503 if persist missing) |
230
+ | `POST /api/goals/:sessionId/resume` | Set status active / statusReason resumed (404 if no goal) |
231
+ | `PUT /api/goals/objective/:sessionId` | Writes the objective file |
232
+ | `GET` / `DELETE` objective | Allowed |
233
+ | Scheduled `goalEnabled` create/edit | Allowed |
234
+ | Existing `goalEnabled` task run | Persist goal after session create; fail before create if persist unwired |
235
+
186
236
  ## Scheduled goals
187
237
 
188
- Scheduled tasks can run as goals: `execution.goalEnabled` (+ optional
189
- `execution.goalTokenBudget`) on a task makes the scheduled-tasks runtime
190
- stamp `metadata.openchamber.goal` onto the fresh session (objective = the
191
- expanded task prompt) and attach the goal-mode intro part to the prompt.
192
- The loop here picks it up from session events like any other goal.
238
+ Scheduled tasks with `execution.goalEnabled` (+ optional
239
+ `execution.goalTokenBudget`) write the objective file, persist an active goal
240
+ into the Host store (same shape as manual create), notify the goal runtime to
241
+ arm, and attach the goal-mode intro to the prompt. Without
242
+ `persistSessionGoal` injected, runs fail **before** session create (no
243
+ create/distill/prompt side effects).
193
244
 
194
245
  ## Limitations
195
246
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Session-goal Host capability for the current OpenCode surface.
3
+ *
4
+ * Goal records live in the OpenChamber session-metadata store
5
+ * (`packages/web/server/lib/session-metadata/`), not OpenCode SessionInfo.
6
+ * When the store + scheduled-task / route persist seams are wired, advertise
7
+ * supported so create·resume·edit paths write trackable goal state.
8
+ *
9
+ * `SESSION_GOAL_UNSUPPORTED_REASON` remains for structured refusals if a
10
+ * future surface must advertise unsupported again.
11
+ */
12
+
13
+ export const SESSION_GOAL_UNSUPPORTED_REASON = 'v2_goal_state_unavailable';
14
+
15
+ /**
16
+ * @returns {{ supported: false, reason: typeof SESSION_GOAL_UNSUPPORTED_REASON }
17
+ * | { supported: true, reason?: undefined }}
18
+ */
19
+ export function getSessionGoalCapability() {
20
+ return {
21
+ supported: true,
22
+ };
23
+ }
24
+
25
+ export function isSessionGoalSupported() {
26
+ return getSessionGoalCapability().supported === true;
27
+ }
28
+
29
+ /**
30
+ * Human-readable refusal for APIs / run history when goal state is unavailable.
31
+ * @param {{ supported: boolean, reason?: string }} [capability]
32
+ */
33
+ export function sessionGoalUnavailableMessage(capability = getSessionGoalCapability()) {
34
+ const reason = typeof capability?.reason === 'string' && capability.reason.trim()
35
+ ? capability.reason.trim()
36
+ : SESSION_GOAL_UNSUPPORTED_REASON;
37
+ return `session goal is unavailable (${reason})`;
38
+ }
39
+
40
+ /**
41
+ * Throw a structured refusal for create / edit / resume / objective-write paths.
42
+ * @param {{ operation?: string }} [options]
43
+ */
44
+ export function assertSessionGoalSupported(options = {}) {
45
+ const capability = getSessionGoalCapability();
46
+ if (capability.supported) {
47
+ return capability;
48
+ }
49
+ const operation = typeof options.operation === 'string' && options.operation.trim()
50
+ ? options.operation.trim()
51
+ : 'goal';
52
+ const error = new Error(`${sessionGoalUnavailableMessage(capability)}: cannot ${operation}`);
53
+ error.statusCode = 501;
54
+ error.code = capability.reason || SESSION_GOAL_UNSUPPORTED_REASON;
55
+ error.capability = capability;
56
+ throw error;
57
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ SESSION_GOAL_UNSUPPORTED_REASON,
4
+ assertSessionGoalSupported,
5
+ getSessionGoalCapability,
6
+ isSessionGoalSupported,
7
+ sessionGoalUnavailableMessage,
8
+ } from './capability.js';
9
+
10
+ describe('session-goal capability', () => {
11
+ it('advertises supported Host goal state (session-metadata store)', () => {
12
+ expect(getSessionGoalCapability()).toEqual({
13
+ supported: true,
14
+ });
15
+ expect(SESSION_GOAL_UNSUPPORTED_REASON).toBe('v2_goal_state_unavailable');
16
+ expect(isSessionGoalSupported()).toBe(true);
17
+ });
18
+
19
+ it('formats a stable unavailable message for run history and APIs', () => {
20
+ expect(sessionGoalUnavailableMessage({
21
+ supported: false,
22
+ reason: SESSION_GOAL_UNSUPPORTED_REASON,
23
+ })).toBe(
24
+ 'session goal is unavailable (v2_goal_state_unavailable)',
25
+ );
26
+ });
27
+
28
+ it('assertSessionGoalSupported returns when supported', () => {
29
+ expect(assertSessionGoalSupported({ operation: 'create' })).toEqual({
30
+ supported: true,
31
+ });
32
+ });
33
+ });
@@ -1,12 +1,234 @@
1
+ import crypto from 'node:crypto';
2
+ import {
3
+ assertSessionGoalSupported,
4
+ getSessionGoalCapability,
5
+ isSessionGoalSupported,
6
+ sessionGoalUnavailableMessage,
7
+ } from './capability.js';
1
8
  import { deleteObjective, readObjective, writeObjective } from './objectives.js';
2
9
 
10
+ const asNonEmptyString = (value) => {
11
+ if (typeof value !== 'string') return null;
12
+ const trimmed = value.trim();
13
+ return trimmed.length > 0 ? trimmed : null;
14
+ };
15
+
16
+ const refuseGoalMutation = (res, operation) => {
17
+ try {
18
+ assertSessionGoalSupported({ operation });
19
+ return false;
20
+ } catch (error) {
21
+ const capability = error?.capability || getSessionGoalCapability();
22
+ res.status(Number(error?.statusCode) || 501).json({
23
+ error: error?.message || sessionGoalUnavailableMessage(capability),
24
+ reason: capability.reason,
25
+ capability,
26
+ operation,
27
+ });
28
+ return true;
29
+ }
30
+ };
31
+
32
+ const resolveTokenBudget = (value) => {
33
+ if (Number.isFinite(value) && value > 0) {
34
+ return Math.floor(value);
35
+ }
36
+ return null;
37
+ };
38
+
39
+ /**
40
+ * Build an active goal record accepted by parseGoalMetadata.
41
+ * File-backed: objectiveFile true (objective may be empty).
42
+ * Inline fallback: objective text required.
43
+ */
44
+ const buildActiveGoalRecord = ({
45
+ objective = '',
46
+ objectiveFile = false,
47
+ tokenBudget = null,
48
+ statusReason = '',
49
+ id = null,
50
+ } = {}) => {
51
+ const now = Date.now();
52
+ const inline = typeof objective === 'string' ? objective.trim() : '';
53
+ return {
54
+ id: typeof id === 'string' && id ? id : crypto.randomUUID(),
55
+ status: 'active',
56
+ objective: objectiveFile ? '' : inline,
57
+ objectiveFile: objectiveFile === true,
58
+ tokenBudget: resolveTokenBudget(tokenBudget),
59
+ tokensUsed: 0,
60
+ turnsUsed: 0,
61
+ blockedStreak: 0,
62
+ note: '',
63
+ statusReason: typeof statusReason === 'string' ? statusReason : '',
64
+ lastAccountedMessageID: '',
65
+ createdAt: now,
66
+ updatedAt: now,
67
+ };
68
+ };
69
+
3
70
  // OpenChamber-owned routes for file-backed goal objectives, keyed by session
4
71
  // id (one goal per session; a new goal overwrites the old file). The UI
5
72
  // writes the objective file before stamping the goal metadata (which only
6
73
  // carries an `objectiveFile: true` flag), reads it back for display, and
7
74
  // deletes it when the goal is removed.
8
- export function registerSessionGoalRoutes(app) {
75
+ //
76
+ // Host goal state lives in the session-metadata store. Create / resume need
77
+ // an injected persistSessionGoal; without it they return 503 rather than
78
+ // pretending success. GET remains readable for leftover files.
79
+ export function registerSessionGoalRoutes(app, deps = {}) {
80
+ const {
81
+ persistSessionGoal = null,
82
+ readSessionMetadata = null,
83
+ /** After create/resume persist — arm the goal loop (synthetic session.updated). */
84
+ onGoalPersisted = null,
85
+ } = deps;
86
+
87
+ const notifyGoalPersisted = (sessionID, directory, persisted, goal) => {
88
+ if (typeof onGoalPersisted !== 'function') return;
89
+ try {
90
+ const metadata = persisted
91
+ && typeof persisted === 'object'
92
+ && !Array.isArray(persisted)
93
+ && persisted.openchamber
94
+ ? persisted
95
+ : { openchamber: { goal } };
96
+ onGoalPersisted({ sessionID, directory: directory || '', metadata });
97
+ } catch (error) {
98
+ console.warn('[session-goal] onGoalPersisted failed:', error?.message || error);
99
+ }
100
+ };
101
+
102
+ app.get('/api/goals/capability', (_req, res) => {
103
+ res.json(getSessionGoalCapability());
104
+ });
105
+
106
+ app.post('/api/goals/:sessionId', async (req, res) => {
107
+ if (refuseGoalMutation(res, 'create')) return;
108
+ if (typeof persistSessionGoal !== 'function') {
109
+ res.status(503).json({
110
+ error: 'session goal persist is not configured',
111
+ operation: 'create',
112
+ capability: getSessionGoalCapability(),
113
+ });
114
+ return;
115
+ }
116
+
117
+ const sessionId = asNonEmptyString(req.params?.sessionId);
118
+ if (!sessionId) {
119
+ res.status(400).json({ error: 'a session id is required' });
120
+ return;
121
+ }
122
+
123
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
124
+ const directory = asNonEmptyString(body.directory) || '';
125
+ const objectiveText = typeof body.objective === 'string' ? body.objective : '';
126
+ const tokenBudget = body.tokenBudget ?? body.goalTokenBudget ?? null;
127
+
128
+ let objectiveFile = false;
129
+ let inlineObjective = objectiveText;
130
+ try {
131
+ await writeObjective(sessionId, objectiveText);
132
+ objectiveFile = true;
133
+ inlineObjective = '';
134
+ } catch (error) {
135
+ console.warn('[session-goal] objective file write failed, using inline objective:', error?.message || error);
136
+ if (!asNonEmptyString(inlineObjective)) {
137
+ res.status(400).json({ error: 'objective is required when the objective file cannot be written' });
138
+ return;
139
+ }
140
+ }
141
+
142
+ const goal = buildActiveGoalRecord({
143
+ objective: inlineObjective,
144
+ objectiveFile,
145
+ tokenBudget,
146
+ });
147
+
148
+ let persisted;
149
+ try {
150
+ persisted = await persistSessionGoal(sessionId, directory, goal);
151
+ } catch (error) {
152
+ const message = error?.message || 'Failed to persist session goal';
153
+ console.error('[session-goal] create persist failed:', message);
154
+ res.status(500).json({ error: message, operation: 'create' });
155
+ return;
156
+ }
157
+
158
+ notifyGoalPersisted(sessionId, directory, persisted, goal);
159
+ res.json({ ok: true, goal });
160
+ });
161
+
162
+ app.post('/api/goals/:sessionId/resume', async (req, res) => {
163
+ if (refuseGoalMutation(res, 'resume')) return;
164
+ if (typeof persistSessionGoal !== 'function') {
165
+ res.status(503).json({
166
+ error: 'session goal persist is not configured',
167
+ operation: 'resume',
168
+ capability: getSessionGoalCapability(),
169
+ });
170
+ return;
171
+ }
172
+ if (typeof readSessionMetadata !== 'function') {
173
+ res.status(503).json({
174
+ error: 'session goal metadata read is not configured',
175
+ operation: 'resume',
176
+ capability: getSessionGoalCapability(),
177
+ });
178
+ return;
179
+ }
180
+
181
+ const sessionId = asNonEmptyString(req.params?.sessionId);
182
+ if (!sessionId) {
183
+ res.status(400).json({ error: 'a session id is required' });
184
+ return;
185
+ }
186
+
187
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
188
+ const directory = asNonEmptyString(body.directory) || '';
189
+
190
+ let metadata;
191
+ try {
192
+ metadata = await readSessionMetadata(sessionId);
193
+ } catch (error) {
194
+ const message = error?.message || 'Failed to read session metadata';
195
+ console.error('[session-goal] resume read failed:', message);
196
+ res.status(503).json({ error: message, operation: 'resume' });
197
+ return;
198
+ }
199
+
200
+ const currentGoal = metadata?.openchamber?.goal;
201
+ if (!currentGoal || typeof currentGoal !== 'object') {
202
+ res.status(404).json({ error: 'goal not found', operation: 'resume' });
203
+ return;
204
+ }
205
+
206
+ const now = Date.now();
207
+ const goal = {
208
+ ...currentGoal,
209
+ status: 'active',
210
+ statusReason: 'resumed',
211
+ updatedAt: now,
212
+ };
213
+
214
+ let persisted;
215
+ try {
216
+ persisted = await persistSessionGoal(sessionId, directory, goal);
217
+ } catch (error) {
218
+ const message = error?.message || 'Failed to persist session goal';
219
+ console.error('[session-goal] resume persist failed:', message);
220
+ res.status(500).json({ error: message, operation: 'resume' });
221
+ return;
222
+ }
223
+
224
+ notifyGoalPersisted(sessionId, directory, persisted, goal);
225
+ res.json({ ok: true, goal });
226
+ });
227
+
9
228
  app.put('/api/goals/objective/:sessionId', async (req, res) => {
229
+ // Objective write is part of manual goal create — refuse while unsupported
230
+ // so create cannot partial-succeed on the file alone.
231
+ if (refuseGoalMutation(res, 'create')) return;
10
232
  try {
11
233
  const { content } = req.body || {};
12
234
  await writeObjective(req.params.sessionId, content);
@@ -30,7 +252,14 @@ export function registerSessionGoalRoutes(app) {
30
252
  });
31
253
 
32
254
  app.delete('/api/goals/objective/:sessionId', async (req, res) => {
255
+ // Clear is allowed even when create is unsupported so leftover files from
256
+ // prior experiments can be removed without implying goal Host support.
33
257
  await deleteObjective(req.params.sessionId);
34
- res.json({ ok: true });
258
+ res.json({
259
+ ok: true,
260
+ ...(isSessionGoalSupported() ? {} : {
261
+ capability: getSessionGoalCapability(),
262
+ }),
263
+ });
35
264
  });
36
265
  }
@@ -0,0 +1,211 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('./objectives.js', () => ({
4
+ writeObjective: vi.fn(async () => ({ content: 'ok' })),
5
+ readObjective: vi.fn(async () => 'leftover'),
6
+ deleteObjective: vi.fn(async () => undefined),
7
+ }));
8
+
9
+ const { writeObjective, readObjective, deleteObjective } = await import('./objectives.js');
10
+ const { registerSessionGoalRoutes } = await import('./routes.js');
11
+
12
+ const createRouteRegistry = () => {
13
+ const routes = new Map();
14
+ return {
15
+ app: {
16
+ get(path, handler) { routes.set(`GET ${path}`, handler); },
17
+ post(path, handler) { routes.set(`POST ${path}`, handler); },
18
+ put(path, handler) { routes.set(`PUT ${path}`, handler); },
19
+ delete(path, handler) { routes.set(`DELETE ${path}`, handler); },
20
+ },
21
+ getRoute(method, path) {
22
+ return routes.get(`${method} ${path}`);
23
+ },
24
+ };
25
+ };
26
+
27
+ const createResponse = () => {
28
+ let statusCode = 200;
29
+ let body = null;
30
+ return {
31
+ status(code) {
32
+ statusCode = code;
33
+ return this;
34
+ },
35
+ json(payload) {
36
+ body = payload;
37
+ return this;
38
+ },
39
+ get statusCode() {
40
+ return statusCode;
41
+ },
42
+ get body() {
43
+ return body;
44
+ },
45
+ };
46
+ };
47
+
48
+ describe('session-goal routes capability gating', () => {
49
+ const register = (deps) => {
50
+ const { app, getRoute } = createRouteRegistry();
51
+ registerSessionGoalRoutes(app, deps);
52
+ return getRoute;
53
+ };
54
+
55
+ it('exposes capability as supported', async () => {
56
+ const getRoute = register();
57
+ const response = createResponse();
58
+ await getRoute('GET', '/api/goals/capability')({}, response);
59
+ expect(response.statusCode).toBe(200);
60
+ expect(response.body).toEqual({
61
+ supported: true,
62
+ });
63
+ });
64
+
65
+ it('returns 503 on create when persist is not injected (no side effects)', async () => {
66
+ writeObjective.mockClear();
67
+ const getRoute = register();
68
+ const response = createResponse();
69
+ await getRoute('POST', '/api/goals/:sessionId')({
70
+ params: { sessionId: 'ses_1' },
71
+ body: { objective: 'ship it' },
72
+ }, response);
73
+
74
+ expect(response.statusCode).toBe(503);
75
+ expect(response.body.operation).toBe('create');
76
+ expect(writeObjective).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it('returns 503 on resume when persist is not injected', async () => {
80
+ const getRoute = register();
81
+ const response = createResponse();
82
+ await getRoute('POST', '/api/goals/:sessionId/resume')({
83
+ params: { sessionId: 'ses_1' },
84
+ }, response);
85
+
86
+ expect(response.statusCode).toBe(503);
87
+ expect(response.body.operation).toBe('resume');
88
+ });
89
+
90
+ it('writes objective when capability is supported', async () => {
91
+ writeObjective.mockClear();
92
+ const getRoute = register();
93
+ const response = createResponse();
94
+ await getRoute('PUT', '/api/goals/objective/:sessionId')({
95
+ params: { sessionId: 'ses_1' },
96
+ body: { content: 'objective text' },
97
+ }, response);
98
+
99
+ expect(response.statusCode).toBe(200);
100
+ expect(response.body).toEqual({ ok: true });
101
+ expect(writeObjective).toHaveBeenCalledWith('ses_1', 'objective text');
102
+ });
103
+
104
+ it('creates an active goal via persist when injected', async () => {
105
+ writeObjective.mockClear();
106
+ const persistSessionGoal = vi.fn(async (_id, _dir, goal) => ({
107
+ openchamber: { goal },
108
+ }));
109
+ const onGoalPersisted = vi.fn();
110
+ const getRoute = register({ persistSessionGoal, onGoalPersisted });
111
+ const response = createResponse();
112
+ await getRoute('POST', '/api/goals/:sessionId')({
113
+ params: { sessionId: 'ses_1' },
114
+ body: { objective: 'ship it', directory: '/repo', tokenBudget: 5000 },
115
+ }, response);
116
+
117
+ expect(response.statusCode).toBe(200);
118
+ expect(response.body.ok).toBe(true);
119
+ expect(response.body.goal).toMatchObject({
120
+ status: 'active',
121
+ objectiveFile: true,
122
+ objective: '',
123
+ tokenBudget: 5000,
124
+ tokensUsed: 0,
125
+ turnsUsed: 0,
126
+ blockedStreak: 0,
127
+ });
128
+ expect(writeObjective).toHaveBeenCalledWith('ses_1', 'ship it');
129
+ expect(persistSessionGoal).toHaveBeenCalledTimes(1);
130
+ expect(persistSessionGoal.mock.calls[0][0]).toBe('ses_1');
131
+ expect(persistSessionGoal.mock.calls[0][1]).toBe('/repo');
132
+ expect(onGoalPersisted).toHaveBeenCalledWith(expect.objectContaining({
133
+ sessionID: 'ses_1',
134
+ directory: '/repo',
135
+ }));
136
+ });
137
+
138
+ it('resumes an existing goal to active with statusReason resumed', async () => {
139
+ const existing = {
140
+ id: 'goal-1',
141
+ status: 'paused',
142
+ objective: 'keep going',
143
+ objectiveFile: false,
144
+ tokenBudget: null,
145
+ tokensUsed: 10,
146
+ turnsUsed: 1,
147
+ blockedStreak: 0,
148
+ note: '',
149
+ statusReason: '',
150
+ lastAccountedMessageID: 'msg_1',
151
+ createdAt: 1,
152
+ updatedAt: 2,
153
+ };
154
+ const readSessionMetadata = vi.fn(async () => ({
155
+ openchamber: { goal: existing },
156
+ }));
157
+ const persistSessionGoal = vi.fn(async (_id, _dir, goal) => ({
158
+ openchamber: { goal },
159
+ }));
160
+ const getRoute = register({ persistSessionGoal, readSessionMetadata });
161
+ const response = createResponse();
162
+ await getRoute('POST', '/api/goals/:sessionId/resume')({
163
+ params: { sessionId: 'ses_1' },
164
+ body: { directory: '/repo' },
165
+ }, response);
166
+
167
+ expect(response.statusCode).toBe(200);
168
+ expect(response.body.goal).toMatchObject({
169
+ id: 'goal-1',
170
+ status: 'active',
171
+ statusReason: 'resumed',
172
+ objective: 'keep going',
173
+ });
174
+ expect(persistSessionGoal).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it('returns 404 on resume when no goal exists', async () => {
178
+ const readSessionMetadata = vi.fn(async () => ({}));
179
+ const persistSessionGoal = vi.fn();
180
+ const getRoute = register({ persistSessionGoal, readSessionMetadata });
181
+ const response = createResponse();
182
+ await getRoute('POST', '/api/goals/:sessionId/resume')({
183
+ params: { sessionId: 'ses_1' },
184
+ }, response);
185
+
186
+ expect(response.statusCode).toBe(404);
187
+ expect(persistSessionGoal).not.toHaveBeenCalled();
188
+ });
189
+
190
+ it('still allows objective read and delete for leftover files', async () => {
191
+ readObjective.mockClear();
192
+ deleteObjective.mockClear();
193
+ const getRoute = register();
194
+
195
+ const getResponse = createResponse();
196
+ await getRoute('GET', '/api/goals/objective/:sessionId')({
197
+ params: { sessionId: 'ses_1' },
198
+ }, getResponse);
199
+ expect(getResponse.statusCode).toBe(200);
200
+ expect(getResponse.body).toEqual({ content: 'leftover' });
201
+ expect(readObjective).toHaveBeenCalledWith('ses_1');
202
+
203
+ const deleteResponse = createResponse();
204
+ await getRoute('DELETE', '/api/goals/objective/:sessionId')({
205
+ params: { sessionId: 'ses_1' },
206
+ }, deleteResponse);
207
+ expect(deleteResponse.statusCode).toBe(200);
208
+ expect(deleteResponse.body.ok).toBe(true);
209
+ expect(deleteObjective).toHaveBeenCalledWith('ses_1');
210
+ });
211
+ });