@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,8 +1,13 @@
1
1
  import crypto from 'node:crypto';
2
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
3
2
  import { DateTime } from 'luxon';
4
3
  import parser from 'cron-parser';
4
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
5
5
  import { expandSnippets } from '../opencode/snippets.js';
6
+ import {
7
+ getSessionGoalCapability,
8
+ isSessionGoalSupported,
9
+ sessionGoalUnavailableMessage,
10
+ } from '../session-goal/capability.js';
6
11
  import { SCHEDULED_SLOT_CLAIMED_CODE } from './run-history-store.js';
7
12
 
8
13
  const DEFAULT_GLOBAL_CONCURRENCY = 4;
@@ -15,16 +20,20 @@ const TASK_DUE_SLACK_MS = 5_000;
15
20
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
16
21
  const PROJECT_SYNC_RETRY_DELAY_MS = 1_000;
17
22
  const MAX_PROJECT_SYNC_RETRIES = 3;
18
- const ARCHIVE_404_RETRY_DELAY_MS = 250;
19
23
  /** Poll interval while waiting for the OpenCode session turn (or goal) to settle. */
20
24
  const SESSION_SETTLEMENT_POLL_MS = 1_000;
21
25
  /**
22
- * Incomplete assistant tails while idle need a couple of stable polls before we
23
- * treat them as settled (OpenCode sometimes leaves time.completed unset).
26
+ * Latest-side message page for settlement. order=desc + bounded limit — never
27
+ * order=asc page-0 + at(-1), which reads an old page once the session exceeds
28
+ * the limit.
24
29
  */
25
- const INCOMPLETE_ASSISTANT_SETTLE_PROBES = 2;
30
+ const SESSION_SETTLEMENT_MESSAGE_LIMIT = 50;
26
31
  /** Goal terminal statuses — active/paused mean the run is still open. */
27
32
  const GOAL_TERMINAL_STATUSES = new Set(['complete', 'blocked', 'budgetLimited']);
33
+ /** Assistant finish values that are real terminal success (not tool-calls mid-loop). */
34
+ const ASSISTANT_SUCCESS_FINISH = new Set(['stop']);
35
+ /** Assistant finish values that are terminal failure without an error object. */
36
+ const ASSISTANT_ERROR_FINISH = new Set(['error', 'length', 'content-filter']);
28
37
 
29
38
  const buildTaskKey = (projectID, taskID) => `${projectID}:${taskID}`;
30
39
 
@@ -235,22 +244,6 @@ export const formatScheduledSessionTitle = (task, nowMs = Date.now()) => {
235
244
  return `${SCHEDULED_TITLE_PREFIX}${trimmedName}${suffix}`;
236
245
  };
237
246
 
238
- const buildScheduledTaskMetadata = ({ projectID, taskID, runID, name }) => ({
239
- openchamber: {
240
- scheduledTask: {
241
- projectID,
242
- taskID,
243
- runID,
244
- name,
245
- },
246
- },
247
- });
248
-
249
- const isMissingSessionError = (result) => {
250
- const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
251
- return status === 404 || result?.error?.code === 'not_found';
252
- };
253
-
254
247
  const sleep = (ms, signal) => new Promise((resolve, reject) => {
255
248
  if (signal?.aborted) {
256
249
  reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
@@ -273,9 +266,11 @@ const sleep = (ms, signal) => new Promise((resolve, reject) => {
273
266
 
274
267
  const readMessageInfo = (entry) => {
275
268
  if (!entry || typeof entry !== 'object') return null;
276
- const info = entry.info && typeof entry.info === 'object' ? entry.info : entry;
277
- if (!info || typeof info !== 'object') return null;
278
- return info;
269
+ // SessionMessageInfo is the entry itself; tolerate projected { info, parts }.
270
+ if (entry.info && typeof entry.info === 'object') {
271
+ return entry.info;
272
+ }
273
+ return entry;
279
274
  };
280
275
 
281
276
  const formatAssistantError = (error) => {
@@ -291,6 +286,79 @@ const formatAssistantError = (error) => {
291
286
  return 'assistant error';
292
287
  };
293
288
 
289
+ const isAssistantMessage = (info) => (
290
+ info
291
+ && typeof info === 'object'
292
+ && (info.type === 'assistant' || info.role === 'assistant')
293
+ );
294
+
295
+ const isUserMessage = (info) => (
296
+ info
297
+ && typeof info === 'object'
298
+ && (info.type === 'user' || info.role === 'user')
299
+ );
300
+
301
+ /**
302
+ * Classify the newest message on a latest-side page.
303
+ * - finish tool-calls is mid-loop, never success
304
+ * - incomplete (no time.completed) is never success
305
+ * - model-switched / non-assistant tails are not success
306
+ * - only a completed assistant with a real terminal finish succeeds
307
+ */
308
+ const classifyMessageTail = (messages) => {
309
+ if (!Array.isArray(messages) || messages.length === 0) {
310
+ return { outcome: 'unknown' };
311
+ }
312
+ // order=desc contract: index 0 is the newest message on this page.
313
+ // Do not re-rank by time.created — that reintroduces the old-page bug when
314
+ // a bounded page mixes ages (and fixtures / clock skew can invert created).
315
+ const latest = readMessageInfo(messages[0]);
316
+ if (!latest) {
317
+ return { outcome: 'unknown' };
318
+ }
319
+ if (isAssistantMessage(latest)) {
320
+ if (latest.error) {
321
+ return {
322
+ outcome: 'error',
323
+ error: formatAssistantError(latest.error),
324
+ };
325
+ }
326
+ const finish = typeof latest.finish === 'string' ? latest.finish.trim() : '';
327
+ if (finish === 'tool-calls') {
328
+ // Model paused for tools — not a successful run terminal.
329
+ return { outcome: 'busy' };
330
+ }
331
+ if (ASSISTANT_ERROR_FINISH.has(finish)) {
332
+ return {
333
+ outcome: 'error',
334
+ error: finish === 'error' ? 'assistant error' : `assistant finish ${finish}`,
335
+ };
336
+ }
337
+ if (latest.time?.completed && (!finish || ASSISTANT_SUCCESS_FINISH.has(finish))) {
338
+ return { outcome: 'success' };
339
+ }
340
+ // Incomplete assistant (no completed, or unknown finish) — not success.
341
+ return { outcome: 'busy' };
342
+ }
343
+ if (isUserMessage(latest)) {
344
+ return { outcome: 'awaiting_assistant', lastInfo: latest };
345
+ }
346
+ // model-switched, compaction, system, etc. — not a success terminal.
347
+ return { outcome: 'busy' };
348
+ };
349
+
350
+ const listLatestMessages = async (client, sessionID, requestOptions) => {
351
+ if (typeof client?.message?.list !== 'function') {
352
+ return null;
353
+ }
354
+ const messagesResult = await client.message.list({
355
+ sessionID,
356
+ limit: SESSION_SETTLEMENT_MESSAGE_LIMIT,
357
+ order: 'desc',
358
+ }, requestOptions);
359
+ return Array.isArray(messagesResult?.data) ? messagesResult.data : null;
360
+ };
361
+
294
362
  const extractGoalFromSession = (session) => {
295
363
  const metadata = session?.metadata;
296
364
  if (!metadata || typeof metadata !== 'object') return null;
@@ -308,9 +376,13 @@ const extractGoalFromSession = (session) => {
308
376
 
309
377
  /**
310
378
  * Single-shot session outcome for post-run continuation (no polling).
311
- * Goal-enabled: terminal complete → success; blocked/budgetLimited → error;
312
- * otherwise not-yet-success. Non-goal: busy/retry, assistant error, or
313
- * completed assistant; incomplete/empty tails are not treated as success.
379
+ * Goal-enabled + supported: terminal complete → success; blocked/budgetLimited
380
+ * → error; otherwise not-yet-success. Goal unsupported: never invent success.
381
+ * Prefer Host `readSessionMetadata` over OpenCode `session.get` (direct clients
382
+ * do not see the Host store). Store read failure is never empty-goal success.
383
+ * Non-goal: session.active failure stays unknown; latest-side assistant tail
384
+ * must be a real terminal (finish tool-calls / incomplete / model-switch ≠
385
+ * success). History rows are never rewritten here.
314
386
  */
315
387
  const snapshotSessionOutcome = async ({
316
388
  client,
@@ -318,76 +390,92 @@ const snapshotSessionOutcome = async ({
318
390
  projectPath,
319
391
  goalEnabled,
320
392
  signal,
393
+ readSessionMetadata = null,
321
394
  }) => {
322
395
  const requestOptions = signal ? { signal } : undefined;
323
396
 
324
- if (goalEnabled && typeof client?.session?.get === 'function') {
325
- try {
326
- const sessionResult = await client.session.get({
327
- sessionID,
328
- directory: projectPath,
329
- }, requestOptions);
330
- if (!sessionResult?.error) {
331
- const goal = extractGoalFromSession(sessionResult?.data);
332
- if (goal && GOAL_TERMINAL_STATUSES.has(goal.status)) {
333
- if (goal.status === 'complete') {
334
- return { outcome: 'success' };
335
- }
336
- return {
337
- outcome: 'error',
338
- error: goal.note || `goal ${goal.status}`,
339
- };
397
+ if (goalEnabled) {
398
+ if (!isSessionGoalSupported()) {
399
+ // Host goal state unavailable — do not promote assistant-tail idle to success.
400
+ return { outcome: 'busy' };
401
+ }
402
+
403
+ const classifyGoal = (goal) => {
404
+ if (goal && GOAL_TERMINAL_STATUSES.has(goal.status)) {
405
+ if (goal.status === 'complete') {
406
+ return { outcome: 'success' };
340
407
  }
408
+ return {
409
+ outcome: 'error',
410
+ error: goal.note || `goal ${goal.status}`,
411
+ };
341
412
  }
342
- } catch (error) {
343
- if (signal?.aborted) throw error;
413
+ return null;
414
+ };
415
+
416
+ // Prefer Host store: OpenCode session.get cannot see openchamber.goal.
417
+ if (typeof readSessionMetadata === 'function') {
418
+ try {
419
+ const metadata = await readSessionMetadata(sessionID);
420
+ const classified = classifyGoal(extractGoalFromSession({ metadata }));
421
+ if (classified) return classified;
422
+ } catch (error) {
423
+ if (signal?.aborted) throw error;
424
+ // Store read failure must not masquerade as empty goal / success.
425
+ return { outcome: 'busy' };
426
+ }
427
+ return { outcome: 'busy' };
344
428
  }
345
- return { outcome: 'busy' };
346
- }
347
429
 
348
- if (typeof client?.session?.status === 'function') {
349
- try {
350
- const statusResult = await client.session.status({
351
- directory: projectPath,
352
- }, requestOptions);
353
- if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
354
- const statusValue = statusResult.data[sessionID];
355
- const type = statusValue?.type ?? statusValue?.status;
356
- if (type === 'busy' || type === 'retry') {
357
- return { outcome: 'busy' };
430
+ if (typeof client?.session?.get === 'function') {
431
+ try {
432
+ const sessionResult = await client.session.get({
433
+ sessionID,
434
+ directory: projectPath,
435
+ }, requestOptions);
436
+ if (!sessionResult?.error) {
437
+ const classified = classifyGoal(
438
+ extractGoalFromSession(sessionResult?.data ?? sessionResult),
439
+ );
440
+ if (classified) return classified;
358
441
  }
442
+ } catch (error) {
443
+ if (signal?.aborted) throw error;
359
444
  }
360
- } catch (error) {
361
- if (signal?.aborted) throw error;
362
445
  }
446
+ return { outcome: 'busy' };
363
447
  }
364
448
 
365
- if (typeof client?.session?.messages === 'function') {
449
+ if (typeof client?.session?.active === 'function') {
366
450
  try {
367
- const messagesResult = await client.session.messages({
368
- sessionID,
369
- directory: projectPath,
370
- limit: 50,
371
- }, requestOptions);
372
- if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
373
- const lastInfo = readMessageInfo(messagesResult.data.at(-1));
374
- if (lastInfo?.role === 'assistant') {
375
- if (lastInfo.error) {
376
- return {
377
- outcome: 'error',
378
- error: formatAssistantError(lastInfo.error),
379
- };
380
- }
381
- if (lastInfo.time?.completed) {
382
- return { outcome: 'success' };
383
- }
384
- }
451
+ const activeMap = await client.session.active(requestOptions);
452
+ if (!(activeMap && typeof activeMap === 'object' && !Array.isArray(activeMap))) {
453
+ // Malformed active payload is unknown — never idle-success.
454
+ return { outcome: 'busy' };
455
+ }
456
+ if (Object.prototype.hasOwnProperty.call(activeMap, sessionID)) {
457
+ return { outcome: 'busy' };
385
458
  }
386
459
  } catch (error) {
387
460
  if (signal?.aborted) throw error;
461
+ // active failure = unknown; do not fall through to message.list success.
462
+ return { outcome: 'busy' };
388
463
  }
389
464
  }
390
465
 
466
+ try {
467
+ const messages = await listLatestMessages(client, sessionID, requestOptions);
468
+ if (!messages) {
469
+ return { outcome: 'busy' };
470
+ }
471
+ const classified = classifyMessageTail(messages);
472
+ if (classified.outcome === 'success' || classified.outcome === 'error') {
473
+ return classified;
474
+ }
475
+ } catch (error) {
476
+ if (signal?.aborted) throw error;
477
+ }
478
+
391
479
  return { outcome: 'busy' };
392
480
  };
393
481
 
@@ -428,6 +516,17 @@ export const createScheduledTasksRuntime = (deps) => {
428
516
  maxGlobalConcurrency = DEFAULT_GLOBAL_CONCURRENCY,
429
517
  maxProjectConcurrency = DEFAULT_PROJECT_CONCURRENCY,
430
518
  maxRunDurationMs = DEFAULT_MAX_RUN_MS,
519
+ /** Host store write: (sessionId, directory, goal) => Promise */
520
+ persistSessionGoal = null,
521
+ /** Host store read: (sessionId) => Promise<metadata|null> */
522
+ readSessionMetadata = null,
523
+ /**
524
+ * After a scheduled goal is first persisted, arm the session-goal loop.
525
+ * (session-goal runtime's own progress writes only broadcast — do not
526
+ * feed those back here.)
527
+ * @type {null | ((event: { sessionID: string, directory: string, metadata: object }) => void)}
528
+ */
529
+ onGoalPersisted = null,
431
530
  } = deps;
432
531
 
433
532
  let started = false;
@@ -702,44 +801,43 @@ export const createScheduledTasksRuntime = (deps) => {
702
801
  + '\n</system-reminder>';
703
802
  };
704
803
 
705
- const buildPromptAsyncPayload = (task, projectPath) => ({
804
+ const buildSessionCreateInput = (task, projectPath, title) => ({
805
+ title,
806
+ location: { directory: projectPath },
807
+ ...(task.execution.agent ? { agent: task.execution.agent } : {}),
706
808
  model: {
809
+ id: task.execution.modelID,
707
810
  providerID: task.execution.providerID,
708
- modelID: task.execution.modelID,
811
+ ...(task.execution.variant ? { variant: task.execution.variant } : {}),
709
812
  },
710
- ...(task.execution.agent ? { agent: task.execution.agent } : {}),
711
- ...(task.execution.variant ? { variant: task.execution.variant } : {}),
712
- parts: [
713
- {
714
- type: 'text',
715
- text: expandSnippets(task.execution.prompt, projectPath),
716
- },
717
- ...(task.execution.goalEnabled
718
- ? [{ type: 'text', text: buildGoalIntroText(task.execution.goalTokenBudget), synthetic: true }]
719
- : []),
720
- ],
721
813
  });
722
814
 
723
- // Scheduled goal runs: stamp the goal onto the fresh session's metadata
724
- // before the prompt goes out; the session-goal runtime picks the loop up
725
- // from session events like any other goal.
815
+ const buildPromptText = (task, projectPath) => {
816
+ const promptText = expandSnippets(task.execution.prompt, projectPath);
817
+ if (!task.execution.goalEnabled) {
818
+ return promptText;
819
+ }
820
+ return `${promptText}\n${buildGoalIntroText(task.execution.goalTokenBudget)}`;
821
+ };
822
+
823
+ // Scheduled goal runs: write the file-backed objective, then persist a
824
+ // trackable goal record into the Host session-metadata store (same seam as
825
+ // manual UI goals). OpenCode SessionInfo has no openchamber.goal channel.
726
826
  const createTaskGoal = async ({
727
- baseUrl,
728
- authHeaders,
729
827
  sessionID,
730
828
  projectPath,
731
829
  task,
732
- scheduledTaskMarker,
733
830
  signal,
734
831
  }) => {
735
832
  signal?.throwIfAborted?.();
736
- const now = Date.now();
737
- // File-backed objective keyed by session id: metadata stays light, the
738
- // full expanded prompt lives under the OpenChamber data dir. If the file
739
- // write fails, fall back to an inline (clamped) objective.
740
- // Oversized prompts are distilled into audit criteria by the small model
741
- // (the working agent gets the full prompt in chat anyway); on distill
742
- // failure a head+tail excerpt keeps intent and acceptance criteria.
833
+ if (typeof persistSessionGoal !== 'function') {
834
+ throw new Error('session goal persist is not configured');
835
+ }
836
+ // File-backed objective keyed by session id: the full expanded prompt
837
+ // lives under the OpenChamber data dir. If the file write fails, fall
838
+ // back to an inline objective so parseGoalMetadata still accepts the
839
+ // record. Oversized prompts are distilled into audit criteria by the
840
+ // small model; on distill failure a head+tail excerpt keeps intent.
743
841
  let objectiveText = expandSnippets(task.execution.prompt, projectPath);
744
842
  if (objectiveText.length > 5000) {
745
843
  let distilled = null;
@@ -788,21 +886,29 @@ export const createScheduledTasksRuntime = (deps) => {
788
886
  try {
789
887
  const { writeObjective } = await import('../session-goal/objectives.js');
790
888
  await writeObjective(sessionID, objectiveText);
791
- signal?.throwIfAborted?.();
792
889
  objectiveFile = true;
890
+ signal?.throwIfAborted?.();
793
891
  } catch (error) {
794
892
  if (signal?.aborted) {
795
893
  throw error;
796
894
  }
797
- console.warn('[scheduled-tasks] goal objective file write failed, falling back to inline:', error?.message || error);
895
+ console.warn('[scheduled-tasks] goal objective file write failed, continuing with inline objective:', error?.message || error);
798
896
  }
799
897
  signal?.throwIfAborted?.();
898
+
899
+ const budgetRaw = task.execution?.goalTokenBudget;
900
+ const tokenBudget = Number.isFinite(budgetRaw) && budgetRaw > 0
901
+ ? Math.floor(budgetRaw)
902
+ : null;
903
+ const now = Date.now();
800
904
  const goal = {
801
- id: `${now.toString(36)}${Math.random().toString(36).slice(2, 8)}`,
802
- objective: objectiveFile ? '' : objectiveText.slice(0, 5000),
803
- objectiveFile,
905
+ id: crypto.randomUUID(),
804
906
  status: 'active',
805
- tokenBudget: task.execution.goalTokenBudget || null,
907
+ // File-backed: flag only (text lives under goals/<sessionId>.md).
908
+ // Inline fallback: full objective text when the file write failed.
909
+ objective: objectiveFile ? '' : objectiveText,
910
+ objectiveFile,
911
+ tokenBudget,
806
912
  tokensUsed: 0,
807
913
  turnsUsed: 0,
808
914
  blockedStreak: 0,
@@ -812,181 +918,109 @@ export const createScheduledTasksRuntime = (deps) => {
812
918
  createdAt: now,
813
919
  updatedAt: now,
814
920
  };
815
- const url = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}`);
816
- url.searchParams.set('directory', projectPath);
817
- // Preserve the scheduledTask marker when goal metadata is patched; a full
818
- // openchamber replace would otherwise drop ownership for history/UI.
819
- const response = await fetch(url.toString(), {
820
- method: 'PATCH',
821
- headers: {
822
- ...authHeaders,
823
- 'content-type': 'application/json',
824
- accept: 'application/json',
825
- },
826
- body: JSON.stringify({
827
- metadata: {
828
- openchamber: {
829
- ...(scheduledTaskMarker ? { scheduledTask: scheduledTaskMarker } : {}),
830
- goal,
831
- },
832
- },
833
- }),
834
- ...(signal ? { signal } : {}),
835
- });
836
- if (!response.ok) {
837
- throw new Error(`goal metadata patch failed (${response.status})`);
838
- }
839
- };
840
921
 
841
- const archiveSessionBeforePrompt = async ({ client, sessionID, projectPath, signal }) => {
842
- const archivePayload = {
843
- sessionID,
844
- directory: projectPath,
845
- time: { archived: Date.now() },
846
- };
847
- const requestOptions = signal ? { signal } : undefined;
848
- let result = await client.session.update(archivePayload, requestOptions);
849
- if (isMissingSessionError(result)) {
850
- await sleep(ARCHIVE_404_RETRY_DELAY_MS, signal);
851
- signal?.throwIfAborted?.();
852
- result = await client.session.update(archivePayload, requestOptions);
853
- }
854
- if (result?.error || (result?.data == null && result?.response?.ok === false)) {
855
- const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status;
856
- const message = result?.error?.message || result?.error?.data?.message || 'session archive failed';
857
- throw new Error(`session archive failed${status ? ` (${status})` : ''}: ${message}`);
922
+ // Persist failure must throw — never prompt as if the goal was registered.
923
+ const persisted = await persistSessionGoal(sessionID, projectPath, goal);
924
+ signal?.throwIfAborted?.();
925
+
926
+ if (typeof onGoalPersisted === 'function') {
927
+ try {
928
+ // Prefer full metadata when the seam returns the store object; otherwise
929
+ // synthesize the openchamber.goal shape the session-goal loop expects.
930
+ const metadata = persisted
931
+ && typeof persisted === 'object'
932
+ && !Array.isArray(persisted)
933
+ && persisted.openchamber
934
+ ? persisted
935
+ : { openchamber: { goal } };
936
+ onGoalPersisted({
937
+ sessionID,
938
+ directory: projectPath,
939
+ metadata,
940
+ });
941
+ } catch (error) {
942
+ console.warn('[scheduled-tasks] onGoalPersisted failed:', error?.message || error);
943
+ }
858
944
  }
859
945
  };
860
946
 
861
- const runPromptAsync = async ({ baseUrl, authHeaders, sessionID, projectPath, task, signal }) => {
947
+ const runSessionPrompt = async ({ client, sessionID, projectPath, task, signal }) => {
862
948
  signal?.throwIfAborted?.();
863
- const promptUrl = new URL(`${baseUrl}/session/${encodeURIComponent(sessionID)}/prompt_async`);
864
- promptUrl.searchParams.set('directory', projectPath);
865
- const response = await fetch(promptUrl.toString(), {
866
- method: 'POST',
867
- headers: {
868
- ...authHeaders,
869
- 'content-type': 'application/json',
870
- accept: 'application/json',
871
- },
872
- body: JSON.stringify(buildPromptAsyncPayload(task, projectPath)),
873
- ...(signal ? { signal } : {}),
874
- });
875
-
876
- if (!response.ok) {
877
- const body = await response.text().catch(() => '');
878
- throw new Error(`prompt_async failed (${response.status})${body ? `: ${body}` : ''}`);
879
- }
949
+ const requestOptions = signal ? { signal } : undefined;
950
+ await client.session.prompt({
951
+ sessionID,
952
+ text: buildPromptText(task, projectPath),
953
+ }, requestOptions);
880
954
  };
881
955
 
882
956
  /**
883
- * prompt_async / command return when the turn is *admitted*, not when the
884
- * agent finishes. History must reflect the real session (or goal) outcome and
885
- * wall-clock duration — poll OpenCode until idle+settled or a terminal goal.
957
+ * session.prompt / command return when the turn is *admitted*, not when the
958
+ * agent finishes. History must reflect the real session outcome and
959
+ * wall-clock duration — poll until idle+settled on a latest-side message
960
+ * page (order=desc). finish tool-calls and incomplete assistants never
961
+ * succeed; two incomplete idle probes must not invent success.
962
+ * session.active / message.list failures stay unknown — never idle or empty
963
+ * success. Goal-enabled runs that passed admission still settle on the
964
+ * assistant tail here when Host goal state is unavailable.
886
965
  */
887
966
  const waitForRunOutcome = async ({
888
967
  client,
889
968
  sessionID,
890
- projectPath,
891
- goalEnabled,
892
969
  signal,
893
970
  }) => {
894
971
  const requestOptions = signal ? { signal } : undefined;
895
- let incompleteAssistantProbes = 0;
896
972
  let emptyIdleProbes = 0;
897
973
 
898
974
  for (;;) {
899
975
  signal?.throwIfAborted?.();
900
976
 
901
- if (goalEnabled && typeof client?.session?.get === 'function') {
977
+ let sessionBusy = null;
978
+ if (typeof client?.session?.active === 'function') {
902
979
  try {
903
- const sessionResult = await client.session.get({
904
- sessionID,
905
- directory: projectPath,
906
- }, requestOptions);
907
- if (!sessionResult?.error) {
908
- const goal = extractGoalFromSession(sessionResult?.data);
909
- if (goal && GOAL_TERMINAL_STATUSES.has(goal.status)) {
910
- if (goal.status === 'complete') {
911
- return { outcome: 'success' };
912
- }
913
- return {
914
- outcome: 'error',
915
- error: goal.note || `goal ${goal.status}`,
916
- };
917
- }
980
+ const activeMap = await client.session.active(requestOptions);
981
+ if (activeMap && typeof activeMap === 'object' && !Array.isArray(activeMap)) {
982
+ sessionBusy = Object.prototype.hasOwnProperty.call(activeMap, sessionID);
918
983
  }
984
+ // Malformed / thrown active stays null → unknown, keep polling.
919
985
  } catch (error) {
920
986
  if (signal?.aborted) throw error;
921
- // Transient get failures: keep polling until watchdog aborts.
922
987
  }
923
988
  }
924
989
 
925
- let sessionBusy = false;
926
- if (typeof client?.session?.status === 'function') {
927
- try {
928
- const statusResult = await client.session.status({
929
- directory: projectPath,
930
- }, requestOptions);
931
- if (!statusResult?.error && statusResult?.data && typeof statusResult.data === 'object') {
932
- const statusValue = statusResult.data[sessionID];
933
- const type = statusValue?.type ?? statusValue?.status;
934
- sessionBusy = type === 'busy' || type === 'retry';
935
- }
936
- } catch (error) {
937
- if (signal?.aborted) throw error;
990
+ if (sessionBusy !== false) {
991
+ if (sessionBusy === true) {
992
+ emptyIdleProbes = 0;
938
993
  }
939
- }
940
-
941
- if (sessionBusy) {
942
- incompleteAssistantProbes = 0;
943
- emptyIdleProbes = 0;
944
994
  await sleep(SESSION_SETTLEMENT_POLL_MS, signal);
945
995
  continue;
946
996
  }
947
997
 
948
- // Non-goal runs settle on the first idle assistant tail. Goal runs only
949
- // finish via terminal goal status above — idle between goal turns is normal.
950
- if (!goalEnabled && typeof client?.session?.messages === 'function') {
951
- try {
952
- const messagesResult = await client.session.messages({
953
- sessionID,
954
- directory: projectPath,
955
- limit: 50,
956
- }, requestOptions);
957
- if (!messagesResult?.error && Array.isArray(messagesResult?.data)) {
958
- const lastInfo = readMessageInfo(messagesResult.data.at(-1));
959
- if (lastInfo?.role === 'assistant') {
960
- emptyIdleProbes = 0;
961
- if (lastInfo.error) {
962
- return {
963
- outcome: 'error',
964
- error: formatAssistantError(lastInfo.error),
965
- };
966
- }
967
- if (lastInfo.time?.completed) {
968
- return { outcome: 'success' };
969
- }
970
- incompleteAssistantProbes += 1;
971
- if (incompleteAssistantProbes >= INCOMPLETE_ASSISTANT_SETTLE_PROBES) {
972
- return { outcome: 'success' };
973
- }
974
- } else {
975
- incompleteAssistantProbes = 0;
976
- emptyIdleProbes += 1;
977
- // Prompt admitted but no assistant yet, or aborted before reply.
978
- // Allow several idle probes so we don't race the first token.
979
- if (emptyIdleProbes >= 5 && lastInfo?.role === 'user') {
980
- return {
981
- outcome: 'error',
982
- error: 'session ended without assistant response',
983
- };
984
- }
998
+ try {
999
+ const messages = await listLatestMessages(client, sessionID, requestOptions);
1000
+ if (messages) {
1001
+ const classified = classifyMessageTail(messages);
1002
+ if (classified.outcome === 'success' || classified.outcome === 'error') {
1003
+ return classified;
1004
+ }
1005
+ if (classified.outcome === 'awaiting_assistant') {
1006
+ emptyIdleProbes += 1;
1007
+ // Prompt admitted but no assistant yet, or aborted before reply.
1008
+ // Allow several idle probes so we don't race the first token.
1009
+ if (emptyIdleProbes >= 5) {
1010
+ return {
1011
+ outcome: 'error',
1012
+ error: 'session ended without assistant response',
1013
+ };
985
1014
  }
1015
+ } else {
1016
+ // busy / unknown tails (tool-calls, incomplete, model-switch, …)
1017
+ // keep polling — never promote incomplete idle to success.
1018
+ emptyIdleProbes = 0;
986
1019
  }
987
- } catch (error) {
988
- if (signal?.aborted) throw error;
989
1020
  }
1021
+ } catch (error) {
1022
+ if (signal?.aborted) throw error;
1023
+ // message.list failure stays unknown — keep polling under the watchdog.
990
1024
  }
991
1025
 
992
1026
  await sleep(SESSION_SETTLEMENT_POLL_MS, signal);
@@ -1003,7 +1037,9 @@ export const createScheduledTasksRuntime = (deps) => {
1003
1037
  const requestOptions = signal ? { signal } : undefined;
1004
1038
  let commands = [];
1005
1039
  try {
1006
- const response = await client.command.list({ directory: projectPath }, requestOptions);
1040
+ const response = await client.command.list({
1041
+ location: { directory: projectPath },
1042
+ }, requestOptions);
1007
1043
  commands = Array.isArray(response?.data) ? response.data : [];
1008
1044
  } catch (error) {
1009
1045
  if (signal?.aborted) {
@@ -1020,28 +1056,30 @@ export const createScheduledTasksRuntime = (deps) => {
1020
1056
 
1021
1057
  await client.session.command({
1022
1058
  sessionID,
1023
- directory: projectPath,
1024
1059
  command: parsed.command,
1025
1060
  arguments: parsed.arguments,
1026
1061
  ...(task.execution.agent ? { agent: task.execution.agent } : {}),
1027
- model: `${task.execution.providerID}/${task.execution.modelID}`,
1028
- ...(task.execution.variant ? { variant: task.execution.variant } : {}),
1062
+ model: {
1063
+ id: task.execution.modelID,
1064
+ providerID: task.execution.providerID,
1065
+ ...(task.execution.variant ? { variant: task.execution.variant } : {}),
1066
+ },
1029
1067
  }, requestOptions);
1030
1068
 
1031
1069
  return true;
1032
1070
  };
1033
1071
 
1034
- const abortCreatedSessionBestEffort = async ({ client, sessionID, projectPath }) => {
1035
- if (!sessionID || !client?.session?.abort) {
1072
+ const interruptCreatedSessionBestEffort = async ({ client, sessionID }) => {
1073
+ if (!sessionID || typeof client?.session?.interrupt !== 'function') {
1036
1074
  return;
1037
1075
  }
1038
1076
  try {
1039
- await client.session.abort({
1077
+ await client.session.interrupt({
1040
1078
  sessionID,
1041
- directory: projectPath,
1079
+ continue: false,
1042
1080
  });
1043
1081
  } catch {
1044
- // Never let upstream abort failure replace the watchdog timeout error.
1082
+ // Never let upstream interrupt failure replace the watchdog timeout error.
1045
1083
  }
1046
1084
  };
1047
1085
 
@@ -1055,6 +1093,20 @@ export const createScheduledTasksRuntime = (deps) => {
1055
1093
 
1056
1094
  signal?.throwIfAborted?.();
1057
1095
 
1096
+ // Goal-enabled tasks: refuse before session create / objective write /
1097
+ // prompt when Host goal state is unavailable OR persist is not wired.
1098
+ // Config + run-history stay; only this run is recorded as error (no
1099
+ // false first-turn success / no create·distill·prompt side effects).
1100
+ if (task.execution?.goalEnabled) {
1101
+ const capability = getSessionGoalCapability();
1102
+ if (!capability.supported) {
1103
+ throw new Error(sessionGoalUnavailableMessage(capability));
1104
+ }
1105
+ if (typeof persistSessionGoal !== 'function') {
1106
+ throw new Error('session goal persist is not configured');
1107
+ }
1108
+ }
1109
+
1058
1110
  if (typeof waitForOpenCodeReady === 'function') {
1059
1111
  await waitForOpenCodeReady(10_000, 250);
1060
1112
  signal?.throwIfAborted?.();
@@ -1062,26 +1114,16 @@ export const createScheduledTasksRuntime = (deps) => {
1062
1114
 
1063
1115
  const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
1064
1116
  const authHeaders = getOpenCodeAuthHeaders();
1065
- const client = createOpencodeClient({
1117
+ const client = makeOpenCodeV2Client({
1066
1118
  baseUrl,
1067
- headers: authHeaders,
1119
+ authHeaders,
1068
1120
  });
1069
1121
  const requestOptions = signal ? { signal } : undefined;
1070
1122
 
1071
- const taskName = typeof task?.name === 'string' && task.name.trim().length > 0
1072
- ? task.name.trim()
1073
- : 'Schedule';
1074
- const scheduledTaskMarker = {
1075
- projectID,
1076
- taskID: task.id,
1077
- runID,
1078
- name: taskName,
1079
- };
1080
-
1081
1123
  let sessionID;
1082
- // Watchdog abort must fire session.abort even while awaiting non-cancellable
1083
- // work (small-model distill / writeObjective). Register once after create;
1084
- // never on ordinary non-timeout failures or success.
1124
+ // Watchdog abort must fire session.interrupt even while awaiting
1125
+ // non-cancellable work (small-model distill / writeObjective). Register
1126
+ // once after create; never on ordinary non-timeout failures or success.
1085
1127
  let sessionAbortStarted = false;
1086
1128
  let onWatchdogAbort = null;
1087
1129
  const triggerSessionAbortOnce = () => {
@@ -1089,7 +1131,7 @@ export const createScheduledTasksRuntime = (deps) => {
1089
1131
  return;
1090
1132
  }
1091
1133
  sessionAbortStarted = true;
1092
- void abortCreatedSessionBestEffort({ client, sessionID, projectPath });
1134
+ void interruptCreatedSessionBestEffort({ client, sessionID });
1093
1135
  };
1094
1136
  const clearWatchdogAbortListener = () => {
1095
1137
  if (onWatchdogAbort && signal) {
@@ -1099,12 +1141,11 @@ export const createScheduledTasksRuntime = (deps) => {
1099
1141
  };
1100
1142
 
1101
1143
  try {
1102
- const sessionResponse = await client.session.create({
1103
- directory: projectPath,
1104
- title,
1105
- metadata: buildScheduledTaskMetadata(scheduledTaskMarker),
1106
- }, requestOptions);
1107
- sessionID = sessionResponse?.data?.id;
1144
+ const session = await client.session.create(
1145
+ buildSessionCreateInput(task, projectPath, title),
1146
+ requestOptions,
1147
+ );
1148
+ sessionID = session?.id;
1108
1149
  if (!sessionID) {
1109
1150
  throw new Error('failed to create session');
1110
1151
  }
@@ -1130,19 +1171,6 @@ export const createScheduledTasksRuntime = (deps) => {
1130
1171
  }
1131
1172
  }
1132
1173
 
1133
- try {
1134
- // Archive must succeed before any goal/prompt/command work. A 404 gets
1135
- // one short bounded retry; any other failure aborts without prompting.
1136
- await archiveSessionBeforePrompt({ client, sessionID, projectPath, signal });
1137
- } catch (archiveError) {
1138
- if (attachFailed) {
1139
- throw new Error(
1140
- `attachSession failed (${safeErrorMessage(attachFailed)}); archive also failed: ${safeErrorMessage(archiveError)}`,
1141
- );
1142
- }
1143
- throw archiveError;
1144
- }
1145
-
1146
1174
  if (attachFailed) {
1147
1175
  throw new Error(`attachSession failed: ${safeErrorMessage(attachFailed)}`);
1148
1176
  }
@@ -1162,12 +1190,9 @@ export const createScheduledTasksRuntime = (deps) => {
1162
1190
 
1163
1191
  if (task.execution.goalEnabled) {
1164
1192
  await createTaskGoal({
1165
- baseUrl,
1166
- authHeaders,
1167
1193
  sessionID,
1168
1194
  projectPath,
1169
1195
  task,
1170
- scheduledTaskMarker,
1171
1196
  signal,
1172
1197
  });
1173
1198
  }
@@ -1182,9 +1207,8 @@ export const createScheduledTasksRuntime = (deps) => {
1182
1207
  signal,
1183
1208
  });
1184
1209
  if (!executedAsCommand) {
1185
- await runPromptAsync({
1186
- baseUrl,
1187
- authHeaders,
1210
+ await runSessionPrompt({
1211
+ client,
1188
1212
  sessionID,
1189
1213
  projectPath,
1190
1214
  task,
@@ -1192,13 +1216,11 @@ export const createScheduledTasksRuntime = (deps) => {
1192
1216
  });
1193
1217
  }
1194
1218
 
1195
- // Do not finalize on admission alone — wait for the real agent turn (or
1196
- // goal loop) so history status/duration match what the user sees in chat.
1219
+ // Do not finalize on admission alone — wait for the real agent turn so
1220
+ // history status/duration match what the user sees in chat.
1197
1221
  const settlement = await waitForRunOutcome({
1198
1222
  client,
1199
1223
  sessionID,
1200
- projectPath,
1201
- goalEnabled: Boolean(task.execution?.goalEnabled),
1202
1224
  signal,
1203
1225
  });
1204
1226
 
@@ -1369,8 +1391,8 @@ export const createScheduledTasksRuntime = (deps) => {
1369
1391
 
1370
1392
  if (raced.timedOut) {
1371
1393
  // Bound: do not await uncancellable steps (distill / writeObjective).
1372
- // session.abort is started by the signal listener on create; later
1373
- // throwIfAborted gates stop goal PATCH / command / prompt.
1394
+ // session.interrupt is started by the signal listener on create;
1395
+ // later throwIfAborted gates stop goal write / command / prompt.
1374
1396
  throw timeoutError;
1375
1397
  }
1376
1398
 
@@ -1701,15 +1723,16 @@ export const createScheduledTasksRuntime = (deps) => {
1701
1723
 
1702
1724
  const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, '');
1703
1725
  const authHeaders = getOpenCodeAuthHeaders();
1704
- const client = createOpencodeClient({
1726
+ const client = makeOpenCodeV2Client({
1705
1727
  baseUrl,
1706
- headers: authHeaders,
1728
+ authHeaders,
1707
1729
  });
1708
1730
  const snapshot = await snapshotSessionOutcome({
1709
1731
  client,
1710
1732
  sessionID,
1711
1733
  projectPath,
1712
1734
  goalEnabled: Boolean(task.execution?.goalEnabled),
1735
+ readSessionMetadata,
1713
1736
  });
1714
1737
  if (snapshot.outcome !== 'success') {
1715
1738
  return;