@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
@@ -2,7 +2,7 @@ import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
- import { createOpencodeClient } from '@opencode-ai/sdk/v2';
5
+ import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
6
6
  import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
7
7
  import { reduceBackfillState } from './history-state.js';
8
8
  import { createContactMemoryReader } from './memory.js';
@@ -117,22 +117,98 @@ export class AssistantError extends Error {
117
117
  const fail = (code, message) => { throw new AssistantError(code, message); };
118
118
  const string = (value, max = 10_000, required = false) => { if (value == null && !required) return null; if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail('validation_error'); return value.trim(); };
119
119
  const nonEmptyString = (value, max = 10_000) => typeof value === 'string' && value.length > 0 && value.length <= max;
120
- const isMissing = (result) => result?.error?.status === 404 || result?.error?.statusCode === 404 || result?.error?.code === 'not_found' || result?.status === 404;
120
+ const isMissing = (result) => {
121
+ if (!result || typeof result !== 'object') return false;
122
+ if (result.error?.status === 404 || result.error?.statusCode === 404 || result.status === 404) return true;
123
+ const code = result.error?.code ?? result.error?.type ?? result.error?._tag ?? result._tag ?? result.code;
124
+ return code === 'not_found' || code === 'SessionNotFoundError' || code === 'MessageNotFoundError';
125
+ };
121
126
  const messagesErrorStatus = (result) => { const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status; return Number.isFinite(status) ? status : null; };
127
+ const getHttpStatus = (error) => {
128
+ const candidates = [error?.cause?.status, error?.status, error?.response?.status];
129
+ for (const value of candidates) { if (Number.isFinite(value)) return value; }
130
+ return undefined;
131
+ };
132
+ // Real @opencode/client throws declared JSON with `_tag` (no HTTP status).
133
+ const isMissingError = (error) => {
134
+ if (getHttpStatus(error) === 404 || isMissing(error)) return true;
135
+ if (!error || typeof error !== 'object') return false;
136
+ const tag = error._tag ?? error.name ?? error.code ?? error.type ?? error.error?._tag;
137
+ return tag === 'SessionNotFoundError' || tag === 'MessageNotFoundError' || tag === 'not_found';
138
+ };
139
+ const ASSISTANT_SUCCESS_FINISH = new Set(['stop']);
140
+ const ASSISTANT_ERROR_FINISH = new Set(['error', 'length', 'content-filter']);
141
+ const GOAL_ACTIVE_STATUSES = new Set(['active', 'paused']);
142
+ const GOAL_ERROR_STATUSES = new Set(['blocked', 'budgetLimited']);
122
143
  const isTransientMessagesFailure = (result, error) => {
123
144
  if (error) {
124
145
  if (error instanceof AssistantError) return false;
125
- if (error?.name === 'TypeError' || error?.name === 'FetchError') return true;
146
+ if (error?.reason === 'Transport' || error?.name === 'TypeError' || error?.name === 'FetchError' || error?.name === 'AbortError') return true;
126
147
  const code = error?.cause?.code || error?.code;
127
148
  if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'EPIPE') return true;
128
- return false;
149
+ const status = getHttpStatus(error);
150
+ if (status == null) return false;
151
+ return status === 408 || status === 429 || status >= 500;
129
152
  }
130
153
  const status = messagesErrorStatus(result);
131
154
  if (status == null) return true;
132
155
  return status === 408 || status === 429 || status >= 500;
133
156
  };
134
157
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
135
- const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
158
+ const sessionRecord = (value) => (value && typeof value === 'object' && value.data && value.id == null ? value.data : value);
159
+ const sessionIDOf = (value) => sessionRecord(value)?.id ?? null;
160
+ const sessionDirectoryOf = (value) => {
161
+ const session = sessionRecord(value);
162
+ return session?.location?.directory ?? session?.directory ?? session?.project?.worktree ?? null;
163
+ };
164
+ const toV2PromptInput = (sessionID, parts, messageID) => {
165
+ const text = (parts || []).filter((part) => part?.type === 'text').map((part) => part.text).join('\n');
166
+ const files = (parts || []).filter((part) => part?.type === 'file').map((part) => ({ uri: part.url, ...(part.filename ? { name: part.filename } : {}) }));
167
+ const agents = (parts || []).filter((part) => part?.type === 'agent').map((part) => ({ name: part.name }));
168
+ return { sessionID, id: messageID, text, ...(files.length ? { files } : {}), ...(agents.length ? { agents } : {}), delivery: 'steer' };
169
+ };
170
+ const projectV2Parts = (entry, sessionID) => {
171
+ if (Array.isArray(entry?.parts)) return entry.parts;
172
+ const messageID = entry?.id;
173
+ const parts = [];
174
+ if (typeof entry?.text === 'string' && entry.text) parts.push({ id: `${messageID}:text`, sessionID, messageID, type: 'text', text: entry.text });
175
+ if (Array.isArray(entry?.files)) entry.files.forEach((file, index) => parts.push({ id: `${messageID}:file:${index}`, sessionID, messageID, type: 'file', mime: file?.mime || 'application/octet-stream', url: file?.uri ?? file?.url, ...(file?.name ? { filename: file.name } : {}) }));
176
+ if (Array.isArray(entry?.content)) entry.content.forEach((item, index) => {
177
+ const partID = item?.id || `${messageID}:content:${index}`;
178
+ if (item?.type === 'text') parts.push({ id: partID, sessionID, messageID, type: 'text', text: item.text ?? '' });
179
+ else if (item?.type === 'reasoning') parts.push({ id: partID, sessionID, messageID, type: 'reasoning', text: item.text ?? '' });
180
+ else if (item?.type === 'tool') parts.push({ id: partID, sessionID, messageID, type: 'tool', tool: item.name, callID: item.id, state: item.state ?? {} });
181
+ });
182
+ return parts;
183
+ };
184
+ const projectProjectionEntry = (entry, sessionID) => {
185
+ if (plainObject(entry?.info)) return { info: entry.info, parts: Array.isArray(entry.parts) ? entry.parts : [] };
186
+ if (!plainObject(entry) || !nonEmptyString(entry.id)) return { info: entry, parts: [] };
187
+ const role = entry.type === 'user' || entry.type === 'assistant' ? entry.type : (entry.role ?? entry.type);
188
+ return { info: { id: entry.id, sessionID: nonEmptyString(entry.sessionID) ? entry.sessionID : sessionID, role, time: entry.time ?? { created: 0 }, ...entry }, parts: projectV2Parts(entry, nonEmptyString(entry.sessionID) ? entry.sessionID : sessionID) };
189
+ };
190
+ const projectionEntries = (payload, sessionID) => {
191
+ const items = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : Array.isArray(payload?.data?.items) ? payload.data.items : null;
192
+ if (!items) return null;
193
+ return items.map((entry) => projectProjectionEntry(entry, sessionID));
194
+ };
195
+ const projectionCursor = (result) => { const next = typeof result?.cursor === 'string' ? result.cursor : result?.cursor?.next; return (typeof next === 'string' && next) ? next : (result?.response?.headers?.get?.('x-next-cursor') ?? null); };
196
+ const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.id != null || result?.type != null || result?.data !== undefined || result?.response?.ok === true);
197
+ const invokeSession = async (work) => {
198
+ try {
199
+ const value = await work();
200
+ if (isMissing(value)) return { error: { status: 404 }, status: 404 };
201
+ if (value?.error) return value;
202
+ return value;
203
+ } catch (error) {
204
+ if (error instanceof AssistantError) throw error;
205
+ if (isMissingError(error)) return { error: { status: 404 }, status: 404 };
206
+ const status = getHttpStatus(error);
207
+ if (status != null) return { error: { status }, status };
208
+ return { error: { status: 500 }, status: 500 };
209
+ }
210
+ };
211
+
136
212
  /** Bound catalog / project list waits so a contact lane cannot hang forever. */
137
213
  export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
138
214
  /** Bound last worker assistant text injected into assigned-session resume prompts. */
@@ -160,7 +236,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
160
236
  }
161
237
  };
162
238
 
163
- export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
239
+ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, fetchImpl, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
164
240
  if (!dbPath || !dataDir) return null;
165
241
  const Database = require('better-sqlite3');
166
242
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -348,30 +424,29 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
348
424
  };
349
425
  };
350
426
  const binding = (row) => ({ sessionID: row.current_session_id, directory: effectiveWorkspace(row), sessionGeneration: row.session_generation });
351
- const client = () => clientFactory ? clientFactory() : createOpencodeClient({ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), headers: getOpenCodeAuthHeaders() });
427
+ const client = () => clientFactory
428
+ ? clientFactory()
429
+ : makeOpenCodeV2Client({
430
+ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
431
+ authHeaders: getOpenCodeAuthHeaders(),
432
+ });
352
433
  const metadata = (row) => ({ openchamber: { assistant: { assistantID: row.assistant_id, name: row.name } } });
353
- const ARCHIVE_RETRY_MS = 25;
354
- const archiveCreatedSession = async (sessionID, directory) => {
355
- const archiveAt = now();
356
- const update = () => client().session.update({ sessionID, directory, time: { archived: archiveAt } });
357
- let result = await update();
358
- if (isMissing(result)) {
359
- await sleep(ARCHIVE_RETRY_MS);
360
- result = await update();
361
- }
362
- if (result?.error || isMissing(result)) fail('upstream_error');
363
- };
364
434
  const createSession = async (row) => {
365
435
  const directory = effectiveWorkspace(row);
366
- const result = await client().session.create({ directory, title: `[Assistant] ${row.name}`, metadata: metadata(row) });
367
- if (result.error || !result.data?.id) fail('upstream_error');
368
- const sessionID = result.data.id;
436
+ const result = await invokeSession(() => client().session.create({
437
+ title: `[Assistant] ${row.name}`,
438
+ location: { directory },
439
+ ...(row.agent ? { agent: row.agent } : {}),
440
+ ...(row.provider_id && row.model_id ? { model: { id: row.model_id, providerID: row.provider_id, ...(row.variant ? { variant: row.variant } : {}) } } : {}),
441
+ }));
442
+ const sessionID = sessionIDOf(result);
443
+ if (result?.error || !sessionID) fail('upstream_error');
369
444
  // Archive before binding so ordinary session lists never flash system sessions.
370
445
  // Metadata still isolates the session if archive fails after create.
371
- await archiveCreatedSession(sessionID, directory);
446
+ // v2 has no session.update archive-metadata write; local history + title prefix are the equivalent isolation.
372
447
  return { sessionID, directory };
373
448
  };
374
- const sessionExists = async (row) => { if (!row.current_session_id) return false; const result = await client().session.get({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) }); if (isMissing(result)) return false; if (result.error) fail('upstream_error'); return Boolean(result.data); };
449
+ const sessionExists = async (row) => { if (!row.current_session_id) return false; const result = await invokeSession(() => client().session.get({ sessionID: row.current_session_id })); if (isMissing(result)) return false; if (result.error) fail('upstream_error'); return Boolean(sessionIDOf(result) || result); };
375
450
  const replaceBinding = (row, created) => { if (row.current_session_id && row.current_session_id !== created.sessionID) archiveSession(row.assistant_id, row.current_session_id, effectiveWorkspace(row)); const result = db.prepare('UPDATE assistant_v2 SET current_session_id=?,session_generation=session_generation+1,updated_at=? WHERE assistant_id=? AND session_generation=? AND tombstone_at IS NULL').run(created.sessionID, now(), row.assistant_id, row.session_generation); if (result.changes) bump(); return result.changes ? assistant(row.assistant_id) : null; };
376
451
  const statelessLanes = new Map();
377
452
  const inStatelessLane = (assistantID, task) => {
@@ -480,8 +555,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
480
555
  db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID);
481
556
  db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=NULL,complete=0,updated_at=excluded.updated_at').run(assistantID, sessionID, null, 0, now());
482
557
  };
483
- const eventSessionID = (properties) => {
484
- const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
558
+ // Accept bridge/legacy `{ properties }` and native v2 `{ data }` envelopes.
559
+ const eventBody = (payload) => {
560
+ if (!plainObject(payload)) return null;
561
+ const data = plainObject(payload.data) ? payload.data : null;
562
+ const properties = plainObject(payload.properties) ? payload.properties : null;
563
+ if (data && Object.keys(data).length > 0) return data;
564
+ if (properties) return properties;
565
+ return data || properties || null;
566
+ };
567
+ const eventSessionID = (body) => {
568
+ const sessionID = body?.sessionID || body?.sessionId || body?.info?.sessionID || body?.info?.sessionId;
485
569
  return nonEmptyString(sessionID) ? sessionID : '';
486
570
  };
487
571
  const isUserAbort = (error) => error?.name === 'MessageAbortedError';
@@ -519,21 +603,39 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
519
603
  if (status === 'cancelled') entry.observed.resume = resume !== false;
520
604
  }
521
605
  };
522
- const settleStatusFromEvent = (payload, properties) => {
523
- if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
524
- if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
525
- if (payload.type === 'session.idle') return 'complete';
526
- if (payload.type === 'session.status') {
527
- const statusType = typeof properties.status?.type === 'string' ? properties.status.type : typeof properties.info?.type === 'string' ? properties.info.type : '';
528
- if (statusType === 'busy' || statusType === 'retry') return 'busy';
606
+ const settleStatusFromEvent = (payload, body) => {
607
+ const type = typeof payload?.type === 'string' ? payload.type : '';
608
+ if (type === 'session.error' || type === 'session.execution.failed') {
609
+ return isUserAbort(body?.error) ? 'cancelled' : 'error';
610
+ }
611
+ // User interrupt: prefer cancelled so contact resume path can notify.
612
+ if (type === 'session.execution.interrupted') {
613
+ return isUserAbort(body?.error) ? 'cancelled' : 'error';
614
+ }
615
+ if (type === 'session.execution.succeeded') return 'complete';
616
+ if (type === 'session.execution.started' || type === 'session.retry.scheduled') return 'busy';
617
+ if (type === 'question.asked' || type === 'permission.asked'
618
+ || type === 'session.pending.question' || type === 'session.pending.permission'
619
+ || type === 'pending.question' || type === 'pending.permission') return 'question';
620
+ if (type === 'session.idle') return 'complete';
621
+ if (type === 'session.status') {
622
+ const statusType = typeof body?.status?.type === 'string' ? body.status.type : typeof body?.info?.type === 'string' ? body.info.type : '';
623
+ if (statusType === 'busy' || statusType === 'retry' || statusType === 'running') return 'busy';
529
624
  if (statusType === 'idle') return 'complete';
530
625
  }
531
626
  return null;
532
627
  };
628
+ // Stash assign delivery message IDs until the session card watch is written.
629
+ const pendingAssignMessageIDs = new Map();
533
630
  // Assigned-session complete/error/cancelled resumes the contact LLM (no canned settle bubble).
534
631
  // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
535
632
  const assignedSessionResumeRef = { schedule: null };
536
- const reportAssignedSession = (sessionID, status, resume = true) => {
633
+ const reportAssignedSession = (sessionID, status, resumeOrOpts = true) => {
634
+ const opts = plainObject(resumeOrOpts)
635
+ ? resumeOrOpts
636
+ : { resume: resumeOrOpts !== false };
637
+ const resume = opts.resume !== false;
638
+ const requireExecutionOwned = opts.requireExecutionOwned === true;
537
639
  const inflight = sessionAbortInflight.get(sessionID);
538
640
  if (inflight) {
539
641
  // Defer watch terminal + continuation until stop_session abort settles.
@@ -554,6 +656,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
554
656
  if (watch.status === status || watch.status === 'cancelled') continue;
555
657
  // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
556
658
  if (status === 'complete' && watch.status === 'error') continue;
659
+ // Weak idle signals must not settle a delivery-watermarked watch — old
660
+ // session.idle can race a reuse assign. Formal execution.* owns that path.
661
+ if (requireExecutionOwned && status === 'complete' && watch.messageID) continue;
557
662
  const updatedAt = now();
558
663
  updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
559
664
  upsertContactWatch(db, {
@@ -563,6 +668,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
563
668
  status,
564
669
  updatedAt,
565
670
  resumeAllowed: watch.resumeAllowed,
671
+ ...(watch.messageID ? { messageID: watch.messageID } : {}),
566
672
  });
567
673
  // complete/error/user-interrupt cancelled: hand result back to contact LLM.
568
674
  // question: card only (worker waiting). stop_session passes resume=false.
@@ -586,7 +692,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
586
692
  }
587
693
  // processEvent stays sync; resume runs on the contact turn lane asynchronously.
588
694
  if (typeof assignedSessionResumeRef.schedule === 'function') {
589
- for (const resume of resumes) assignedSessionResumeRef.schedule(resume);
695
+ for (const item of resumes) assignedSessionResumeRef.schedule(item);
590
696
  }
591
697
  return changed;
592
698
  };
@@ -596,26 +702,134 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
596
702
  if (typeof session?.type === 'string') return session.type;
597
703
  return '';
598
704
  };
705
+ const readMessageInfo = (entry) => {
706
+ if (!entry || typeof entry !== 'object') return null;
707
+ if (plainObject(entry.info)) return entry.info;
708
+ return entry;
709
+ };
710
+ const isAssistantMessage = (info) => info && (info.type === 'assistant' || info.role === 'assistant');
711
+ const messageListItems = (result) => {
712
+ if (!result || result.error) return null;
713
+ if (Array.isArray(result.data)) return result.data;
714
+ if (Array.isArray(result)) return result;
715
+ if (Array.isArray(result.data?.items)) return result.data.items;
716
+ return null;
717
+ };
718
+ const activeMapOf = (value) => {
719
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.error) return null;
720
+ // Tolerate `{ data: { ses_x: … } }` wrappers without treating a bare map as nested data.
721
+ if (value.data && typeof value.data === 'object' && !Array.isArray(value.data) && value.id == null
722
+ && !Object.prototype.hasOwnProperty.call(value, 'type')) {
723
+ const nested = value.data;
724
+ if (!Array.isArray(nested) && typeof nested === 'object') return nested;
725
+ }
726
+ return value;
727
+ };
728
+ const goalOfSession = (session) => {
729
+ const goal = session?.metadata?.openchamber?.goal;
730
+ if (!goal || typeof goal !== 'object') return null;
731
+ const status = typeof goal.status === 'string' ? goal.status.trim() : '';
732
+ return status ? { status } : null;
733
+ };
734
+ /** Messages that belong to this delivery watermark (assign prompt messageID). */
735
+ const messagesOwnedByDelivery = (messages, deliveryMessageID) => {
736
+ if (!Array.isArray(messages)) return [];
737
+ if (!nonEmptyString(deliveryMessageID)) return messages;
738
+ const rows = messages.map((entry) => ({ entry, info: readMessageInfo(entry) }));
739
+ const delivery = rows.find((row) => row.info?.id === deliveryMessageID);
740
+ if (delivery) {
741
+ const created = typeof delivery.info?.time?.created === 'number' ? delivery.info.time.created : null;
742
+ return rows
743
+ .filter((row) => {
744
+ if (row.info?.id === deliveryMessageID) return true;
745
+ const rowCreated = typeof row.info?.time?.created === 'number' ? row.info.time.created : null;
746
+ if (created != null && rowCreated != null) {
747
+ if (rowCreated > created) return true;
748
+ if (rowCreated < created) return false;
749
+ }
750
+ // Ascending msg_ ids: strictly after the delivery user row.
751
+ return typeof row.info?.id === 'string' && row.info.id > deliveryMessageID;
752
+ })
753
+ .map((row) => row.entry);
754
+ }
755
+ // Delivery not on this page — only keep ids after the watermark.
756
+ return rows
757
+ .filter((row) => typeof row.info?.id === 'string' && row.info.id > deliveryMessageID)
758
+ .map((row) => row.entry);
759
+ };
599
760
  const lastAssistantInfo = (messages) => {
600
- if (!Array.isArray(messages)) return null;
601
- for (let index = messages.length - 1; index >= 0; index -= 1) {
602
- const info = messages[index]?.info ?? messages[index];
603
- if (info?.role === 'assistant') return info;
761
+ if (!Array.isArray(messages) || messages.length === 0) return null;
762
+ // Prefer newest by time.created; fall back to array order (desc list → index 0).
763
+ let best = null;
764
+ let bestCreated = Number.NEGATIVE_INFINITY;
765
+ let bestIndex = -1;
766
+ for (let index = 0; index < messages.length; index += 1) {
767
+ const info = readMessageInfo(messages[index]);
768
+ if (!isAssistantMessage(info)) continue;
769
+ const created = typeof info?.time?.created === 'number' ? info.time.created : Number.NEGATIVE_INFINITY;
770
+ if (!best || created > bestCreated || (created === bestCreated && index < bestIndex)) {
771
+ best = info;
772
+ bestCreated = created;
773
+ bestIndex = index;
774
+ }
604
775
  }
776
+ if (best) return best;
777
+ // Desc page without timestamps: first assistant from the start.
778
+ for (let index = 0; index < messages.length; index += 1) {
779
+ const info = readMessageInfo(messages[index]);
780
+ if (isAssistantMessage(info)) return info;
781
+ }
782
+ return null;
783
+ };
784
+ const classifyOwnedAssistantTail = (assistantInfo) => {
785
+ if (!assistantInfo) return null;
786
+ if (assistantInfo.error) {
787
+ // MessageAbortedError → cancelled for contact resume; other errors fail the card.
788
+ return isUserAbort(assistantInfo.error) ? 'cancelled' : 'error';
789
+ }
790
+ const finish = typeof assistantInfo.finish === 'string' ? assistantInfo.finish.trim() : '';
791
+ // Mid-loop tool-calls (even with time.completed) are intermediate steps.
792
+ if (finish === 'tool-calls') return null;
793
+ if (ASSISTANT_ERROR_FINISH.has(finish)) return 'error';
794
+ if (assistantInfo.time?.completed && (!finish || ASSISTANT_SUCCESS_FINISH.has(finish))) return 'complete';
795
+ if (!finish && !assistantInfo.time?.completed) return null;
605
796
  return null;
606
797
  };
607
- const inferAssignedSessionSettleStatus = (getResult, messagesResult) => {
798
+ const inferAssignedSessionSettleStatus = (getResult, messagesResult, { deliveryMessageID = null, activeBusy = null } = {}) => {
608
799
  if (isMissing(getResult)) return 'complete';
609
800
  if (getResult?.error) return null;
610
- const session = getResult?.data;
801
+ // Authoritative active membership wins over stale get snapshots.
802
+ if (activeBusy === true) return null;
803
+ const session = sessionRecord(getResult);
611
804
  if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
805
+ const goal = goalOfSession(session);
806
+ // Goal settle is owned by the session-goal reporter — never invent success.
807
+ if (goal && GOAL_ACTIVE_STATUSES.has(goal.status)) return null;
808
+ if (goal && GOAL_ERROR_STATUSES.has(goal.status)) return 'error';
809
+ if (goal && goal.status === 'complete') return 'complete';
612
810
  const statusType = sessionStatusType(session);
613
- if (statusType === 'busy' || statusType === 'retry') return null;
811
+ if (statusType === 'busy' || statusType === 'retry' || statusType === 'running') return null;
614
812
  if (isMissing(messagesResult)) return 'complete';
615
- const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
616
- if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
617
- if (statusType === 'idle' || session?.time?.completed) return 'complete';
618
- if (assistant?.time?.completed) return 'complete';
813
+ if (messagesResult?.error) return null;
814
+ const items = messageListItems(messagesResult);
815
+ if (!items) return null;
816
+ const owned = messagesOwnedByDelivery(items, deliveryMessageID);
817
+ // Reused session: only pre-delivery assistants remain → wait for this run.
818
+ if (nonEmptyString(deliveryMessageID) && owned.length === 0) return null;
819
+ if (nonEmptyString(deliveryMessageID) && !owned.some((entry) => readMessageInfo(entry)?.id === deliveryMessageID)
820
+ && !owned.some((entry) => isAssistantMessage(readMessageInfo(entry)))) {
821
+ return null;
822
+ }
823
+ const assistantInfo = lastAssistantInfo(owned);
824
+ const fromTail = classifyOwnedAssistantTail(assistantInfo);
825
+ if (fromTail) return fromTail;
826
+ // No owned assistant yet after delivery — keep waiting (superseded / not started).
827
+ if (nonEmptyString(deliveryMessageID) && !assistantInfo) return null;
828
+ // Legacy watches without watermark: idle / session completed may settle.
829
+ if (!nonEmptyString(deliveryMessageID)) {
830
+ if (statusType === 'idle' || session?.time?.completed) return 'complete';
831
+ if (assistantInfo?.time?.completed && assistantInfo.finish !== 'tool-calls') return 'complete';
832
+ }
619
833
  return null;
620
834
  };
621
835
  const resolveWatchDirectory = (watch) => {
@@ -634,26 +848,50 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
634
848
  } catch {
635
849
  return;
636
850
  }
851
+ if (watches.length === 0) return;
852
+ // One active query per reconcile — authoritative busy membership map.
853
+ let activeMap = null;
854
+ let activeKnown = false;
855
+ try {
856
+ const api = client();
857
+ if (typeof api.session?.active === 'function') {
858
+ const raw = await api.session.active();
859
+ activeMap = activeMapOf(raw);
860
+ activeKnown = activeMap != null;
861
+ }
862
+ } catch {
863
+ activeKnown = false;
864
+ }
865
+ if (closed) return;
637
866
  for (const watch of watches) {
638
867
  if (closed) return;
639
868
  try {
869
+ const activeBusy = activeKnown
870
+ ? Object.prototype.hasOwnProperty.call(activeMap, watch.sessionID)
871
+ : null;
872
+ if (activeBusy === true) continue;
640
873
  const directory = resolveWatchDirectory(watch);
641
874
  if (!directory) continue;
642
875
  let getResult;
643
876
  try {
644
- getResult = await client().session.get({ sessionID: watch.sessionID, directory });
645
- } catch {
646
- continue;
877
+ getResult = await invokeSession(() => client().session.get({ sessionID: watch.sessionID, directory }));
878
+ } catch (error) {
879
+ if (isMissingError(error)) getResult = { error: { status: 404 }, status: 404 };
880
+ else continue;
647
881
  }
648
882
  if (closed) return;
649
883
  let messagesResult = null;
650
884
  try {
651
- messagesResult = await client().session.messages({ sessionID: watch.sessionID, directory, limit: 100 });
652
- } catch {
653
- messagesResult = null;
885
+ messagesResult = await invokeSession(() => client().message.list({ sessionID: watch.sessionID, limit: 100, order: 'desc' }));
886
+ } catch (error) {
887
+ if (isMissingError(error)) messagesResult = { error: { status: 404 }, status: 404 };
888
+ else messagesResult = null;
654
889
  }
655
890
  if (closed) return;
656
- const status = inferAssignedSessionSettleStatus(getResult, messagesResult);
891
+ const status = inferAssignedSessionSettleStatus(getResult, messagesResult, {
892
+ deliveryMessageID: watch.messageID,
893
+ activeBusy,
894
+ });
657
895
  if (status) reportAssignedSession(watch.sessionID, status);
658
896
  } catch {
659
897
  // One failed watch must not block unrelated watches.
@@ -662,47 +900,69 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
662
900
  };
663
901
  const processEvent = (event) => {
664
902
  const payload = event?.payload?.payload ?? event?.payload ?? event;
665
- const properties = payload?.properties;
666
- if (!plainObject(payload) || !plainObject(properties)) return false;
903
+ if (!plainObject(payload) || typeof payload.type !== 'string') return false;
904
+ const body = eventBody(payload);
905
+ // Message lifecycle still requires a body; execution/status may carry sessionID on body only.
667
906
  if (payload.type === 'message.updated') {
668
- const info = properties.info; const sessionID = info?.sessionID;
907
+ if (!plainObject(body)) return false;
908
+ const info = body.info; const sessionID = info?.sessionID;
669
909
  if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
670
910
  const cancelled = info.role === 'assistant' && isUserAbort(info.error)
671
911
  ? reportAssignedSession(sessionID, 'cancelled') : false;
672
912
  const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { const current = assistant(assistantID)?.current_session_id === sessionID; mirrorMessage(assistantID, sessionID, info, undefined, current); if (!current) invalidateBackfill(assistantID, sessionID); } return cancelled || assistants.length > 0;
673
913
  }
674
914
  if (payload.type === 'message.part.updated') {
675
- const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
915
+ if (!plainObject(body)) return false;
916
+ const part = body.part; const sessionID = body.sessionID ?? part?.sessionID;
676
917
  if (!nonEmptyString(sessionID) || !plainObject(part)) return false;
677
918
  const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare("DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id GLOB 'oc_asst_admission:*'").run(assistantID, sessionID, part.messageID); mirrorPart(assistantID, sessionID, part); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.length > 0;
678
919
  }
679
920
  if (payload.type === 'message.removed') {
680
- const sessionID = properties.sessionID; const messageID = properties.messageID;
921
+ if (!plainObject(body)) return false;
922
+ const sessionID = body.sessionID; const messageID = body.messageID;
681
923
  if (!nonEmptyString(sessionID) || !nonEmptyString(messageID)) return false;
682
924
  const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID); db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID); } return assistants.length > 0;
683
925
  }
684
926
  if (payload.type === 'message.part.removed') {
685
- const sessionID = properties.sessionID; const messageID = properties.messageID ?? properties.part?.messageID; const partID = properties.partID ?? properties.part?.id;
927
+ if (!plainObject(body)) return false;
928
+ const sessionID = body.sessionID; const messageID = body.messageID ?? body.part?.messageID; const partID = body.partID ?? body.part?.id;
686
929
  if (!nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID)) return false;
687
930
  const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id=?').run(assistantID, sessionID, messageID, partID); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateMessageCoverage(assistantID, sessionID, messageID); } return assistants.length > 0;
688
931
  }
932
+ const sessionID = plainObject(body) ? eventSessionID(body) : '';
933
+ if (payload.type === 'session.execution.started' || payload.type === 'session.retry.scheduled') {
934
+ if (!sessionID) return false;
935
+ return reportAssignedSession(sessionID, 'busy');
936
+ }
937
+ if (payload.type === 'session.execution.succeeded'
938
+ || payload.type === 'session.execution.failed'
939
+ || payload.type === 'session.execution.interrupted') {
940
+ if (!sessionID) return false;
941
+ const status = settleStatusFromEvent(payload, body);
942
+ if (!status) return false;
943
+ const reported = reportAssignedSession(sessionID, status);
944
+ const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
945
+ return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
946
+ }
689
947
  if (payload.type === 'session.idle' || payload.type === 'session.error') {
690
- const sessionID = eventSessionID(properties);
691
948
  if (!sessionID) return false;
692
- const reported = reportAssignedSession(sessionID, settleStatusFromEvent(payload, properties));
949
+ // session.idle is a weak fallback: watermarked watches need execution.* or reconcile.
950
+ const status = settleStatusFromEvent(payload, body);
951
+ const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: payload.type === 'session.idle' });
693
952
  const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
694
953
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
695
954
  }
696
955
  if (payload.type === 'session.status') {
697
- const sessionID = eventSessionID(properties);
698
- const status = settleStatusFromEvent(payload, properties);
699
- if (!sessionID || !status) return false;
700
- const reported = reportAssignedSession(sessionID, status);
956
+ if (!sessionID) return false;
957
+ const status = settleStatusFromEvent(payload, body);
958
+ if (!status) return false;
959
+ const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: status === 'complete' });
701
960
  const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
702
961
  return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
703
962
  }
704
- if (payload.type === 'question.asked' || payload.type === 'permission.asked') {
705
- const sessionID = eventSessionID(properties);
963
+ if (payload.type === 'question.asked' || payload.type === 'permission.asked'
964
+ || payload.type === 'session.pending.question' || payload.type === 'session.pending.permission'
965
+ || payload.type === 'pending.question' || payload.type === 'pending.permission') {
706
966
  if (!sessionID) return false;
707
967
  return reportAssignedSession(sessionID, 'question');
708
968
  }
@@ -741,7 +1001,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
741
1001
  const fetchBackfillMessages = async (history, directory, cursor) => {
742
1002
  for (let attempt = 0; attempt < BACKFILL_MESSAGES_ATTEMPTS; attempt++) {
743
1003
  try {
744
- const result = await client().session.messages({ sessionID: history.session_id, ...(directory ? { directory } : {}), limit: BACKFILL_PAGE_SIZE, ...(cursor ? { before: cursor } : {}) });
1004
+ const result = await invokeSession(() => client().message.list({ sessionID: history.session_id, limit: BACKFILL_PAGE_SIZE, order: 'desc', ...(cursor ? { cursor } : {}) }));
745
1005
  if (!result?.error || isMissing(result)) return result;
746
1006
  if (!isTransientMessagesFailure(result, null) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) fail('upstream_error');
747
1007
  } catch (error) {
@@ -768,12 +1028,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
768
1028
  const result = await fetchBackfillMessages(history, directory, cursor);
769
1029
  if (isMissing(result)) return completeMissingSessionBackfill(history.assistant_id, history.session_id);
770
1030
  if (result?.error) fail('upstream_error');
771
- const entries = Array.isArray(result?.data) ? result.data : Array.isArray(result?.data?.items) ? result.data.items : null;
1031
+ const entries = projectionEntries(result, history.session_id);
772
1032
  if (!entries) fail('upstream_error');
773
1033
  db.exec('BEGIN IMMEDIATE');
774
1034
  try {
775
1035
  entries.forEach((entry) => { const info = entry?.info ?? entry; if (nonEmptyString(info?.sessionID) && info.sessionID !== history.session_id) return; const parts = Array.isArray(entry?.parts) ? entry.parts : []; const messageOrdinal = Number.isSafeInteger(info?.time?.created) ? info.time.created : undefined; mirrorMessage(history.assistant_id, history.session_id, info, messageOrdinal, true); const partIDs = parts.filter((part) => nonEmptyString(part?.id)).map((part) => part.id); if (partIDs.length) db.prepare(`DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id NOT IN (${partIDs.map(() => '?').join(',')})`).run(history.assistant_id, history.session_id, info?.id, ...partIDs); else db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(history.assistant_id, history.session_id, info?.id); parts.forEach((part, partIndex) => mirrorPart(history.assistant_id, history.session_id, part, partIndex + 1)); });
776
- const nextCursor = result?.response?.headers?.get('x-next-cursor') ?? null;
1036
+ const nextCursor = projectionCursor(result) ?? result?.response?.headers?.get?.('x-next-cursor') ?? null;
777
1037
  const next = reduceBackfillState({ cursor, complete: false }, { type: 'page', nextCursor });
778
1038
  // Successful pages only upsert authoritative rows; never delete provisional
779
1039
  // event mirrors that the snapshot omitted — only 404 / message.removed /
@@ -785,6 +1045,31 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
785
1045
  };
786
1046
  const decodeCursor = (value) => { if (value == null || value === '') return null; try { const parsed = parse(Buffer.from(String(value), 'base64url').toString('utf8')); return Number.isSafeInteger(parsed?.sessionOrdinal) && Number.isSafeInteger(parsed?.messageOrdinal) && nonEmptyString(parsed?.messageID) && (parsed.scanSessionOrdinal == null || Number.isSafeInteger(parsed.scanSessionOrdinal)) ? parsed : fail('validation_error'); } catch (error) { if (error instanceof AssistantError) throw error; fail('validation_error'); } };
787
1047
  const encodeCursor = (row, scanSessionOrdinal = row.session_ordinal) => Buffer.from(json({ sessionOrdinal: row.session_ordinal, messageOrdinal: row.message_ordinal, messageID: row.message_id, scanSessionOrdinal })).toString('base64url');
1048
+ const loadProjectionSession = async (history, pageLimit = BACKFILL_PAGE_SIZE) => {
1049
+ let directory = history.directory ?? null;
1050
+ if (directory == null) {
1051
+ const session = await invokeSession(() => client().session.get({ sessionID: history.session_id })).catch(() => null);
1052
+ const resolved = resolveArchivedDirectory(history.assistant_id, sessionDirectoryOf(session), session?.data?.directory, session?.data?.project?.worktree);
1053
+ if (resolved) {
1054
+ db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=? AND directory IS NULL').run(resolved, history.assistant_id, history.session_id);
1055
+ directory = resolved;
1056
+ }
1057
+ }
1058
+ const collected = [];
1059
+ let cursor = null;
1060
+ for (let page = 0; page < BACKFILL_MAX_PAGES; page++) {
1061
+ const result = await fetchBackfillMessages(history, directory, cursor);
1062
+ if (isMissing(result)) return { entries: collected, directory, missing: true };
1063
+ if (result?.error) fail('upstream_error');
1064
+ const entries = projectionEntries(result, history.session_id);
1065
+ if (!entries) fail('upstream_error');
1066
+ collected.push(...entries);
1067
+ cursor = projectionCursor(result);
1068
+ if (!cursor) break;
1069
+ if (collected.length >= pageLimit) break;
1070
+ }
1071
+ return { entries: collected.slice(0, pageLimit), directory, missing: false };
1072
+ };
788
1073
  const historicalMessages = async (assistantID, input = {}) => {
789
1074
  const row = editable(assistantID); const limit = input.limit == null ? 50 : Number(input.limit); if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error'); const before = decodeCursor(input.before);
790
1075
  const currentSessionOrdinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(row.assistant_id).next);
@@ -820,22 +1105,50 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
820
1105
  rows = pageRows();
821
1106
  }
822
1107
  const page = rows.slice(0, limit); const oldest = page[page.length - 1]; const remaining = nextIncomplete(oldest?.session_ordinal ?? before?.scanSessionOrdinal ?? before?.sessionOrdinal ?? null); const nextCursor = oldest ? (rows.length > limit || remaining ? encodeCursor(oldest, oldest.session_ordinal) : null) : remaining ? encodeCursor({ session_ordinal: remaining.ordinal, message_ordinal: Number.MAX_SAFE_INTEGER, message_id: '\uffff' }, remaining.ordinal) : null;
823
- const ordered = [...page].reverse().map((message) => ({ sessionID: message.session_id, directory: message.directory, info: parse(message.info_json), parts: db.prepare('SELECT part_json FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? ORDER BY ordinal ASC,part_id ASC').all(row.assistant_id, message.session_id, message.message_id).map((part) => parse(part.part_json)) }));
1108
+ let ordered = [...page].reverse().map((message) => ({ sessionID: message.session_id, directory: message.directory, info: parse(message.info_json), parts: db.prepare('SELECT part_json FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? ORDER BY ordinal ASC,part_id ASC').all(row.assistant_id, message.session_id, message.message_id).map((part) => parse(part.part_json)) }));
1109
+ // Current binding may still be live-only: if the mirror page has no
1110
+ // current-session rows, read the v2 projection instead of returning empty.
1111
+ if (page.length === 0 && row.current_session_id && !currentIsArchived) {
1112
+ try {
1113
+ const loaded = await loadProjectionSession({ assistant_id: row.assistant_id, session_id: row.current_session_id, directory: effectiveWorkspace(row), ordinal: currentSessionOrdinal }, limit);
1114
+ const live = [];
1115
+ for (const entry of loaded.entries) {
1116
+ const info = entry?.info ?? entry;
1117
+ if (!plainObject(info) || !nonEmptyString(info.id)) continue;
1118
+ if (nonEmptyString(info.sessionID) && info.sessionID !== row.current_session_id) continue;
1119
+ live.push({
1120
+ sessionID: row.current_session_id,
1121
+ directory: loaded.directory,
1122
+ info,
1123
+ parts: Array.isArray(entry?.parts) ? entry.parts : [],
1124
+ });
1125
+ }
1126
+ ordered = [...live, ...ordered];
1127
+ } catch {
1128
+ // Keep archived/mirror rows; current live projection is best-effort.
1129
+ }
1130
+ }
824
1131
  return { entries: ordered, nextCursor, complete: nextCursor === null };
825
1132
  };
826
1133
  for (const current of db.prepare('SELECT * FROM assistant_v2 WHERE current_session_id IS NOT NULL AND tombstone_at IS NULL').all()) void backfillSession({ assistant_id: current.assistant_id, session_id: current.current_session_id, directory: effectiveWorkspace(current) }).catch(() => {});
827
1134
  const unsubscribeEvents = typeof globalEventHub?.subscribeEvent === 'function' ? globalEventHub.subscribeEvent(processEvent) : null;
828
1135
  const createAssistant = (input) => { const allowed = new Set(['enabled', 'name', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const mode = input.mode == null ? 'continuous' : input.mode === 'stateless' || input.mode === 'continuous' ? input.mode : fail('validation_error'); const assistantID = id(); const workspacePath = input.workspacePath == null ? null : workspace(input.workspacePath, assistantID); effectiveWorkspace({ assistant_id: assistantID, workspace_path: workspacePath }); const at = now(); db.exec('BEGIN IMMEDIATE'); try { if (Number(db.prepare('SELECT COUNT(*) AS count FROM assistant_v2 WHERE tombstone_at IS NULL').get().count) >= 100) fail('assistant_limit'); db.prepare('INSERT INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(assistantID, 1, input.enabled === false ? 0 : 1, string(input.name, 256, true), input.defaultPrompt ? string(input.defaultPrompt, 200_000) : '', workspacePath, string(input.providerID, 256, true), string(input.modelID, 256, true), input.agent == null ? null : string(input.agent, 256), input.variant == null ? null : string(input.variant, 256), mode, null, 0, at, at, null); bump(); db.exec('COMMIT'); return output(assistant(assistantID)); } catch (error) { db.exec('ROLLBACK'); throw error; } };
829
1136
  const updateAssistant = async (assistantID, input) => { const row = editable(assistantID); const allowed = new Set(['expectedRevision', 'enabled', 'name', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || !Number.isInteger(input.expectedRevision) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const next = { enabled: input.enabled === undefined ? row.enabled : input.enabled ? 1 : 0, name: input.name === undefined ? row.name : string(input.name, 256, true), prompt: input.defaultPrompt === undefined ? row.default_prompt : string(input.defaultPrompt, 200_000), provider: input.providerID === undefined ? row.provider_id : string(input.providerID, 256, true), model: input.modelID === undefined ? row.model_id : string(input.modelID, 256, true), agent: input.agent === undefined ? row.agent : input.agent === null ? null : string(input.agent, 256), variant: input.variant === undefined ? row.variant : input.variant === null ? null : string(input.variant, 256), mode: input.mode === undefined ? (row.mode === 'stateless' ? 'stateless' : 'continuous') : input.mode === 'continuous' || input.mode === 'stateless' ? input.mode : fail('validation_error'), workspacePath: input.workspacePath === undefined ? row.workspace_path : input.workspacePath == null ? null : workspace(input.workspacePath, assistantID) }; const nextRow = { ...row, workspace_path: next.workspacePath, name: next.name }; effectiveWorkspace(nextRow); const workspaceChanged = next.workspacePath !== row.workspace_path; if (workspaceChanged && row.current_session_id) archiveSession(assistantID, row.current_session_id); const created = workspaceChanged ? await createSession(nextRow) : null; const result = db.prepare('UPDATE assistant_v2 SET enabled=?,name=?,default_prompt=?,provider_id=?,model_id=?,agent=?,variant=?,mode=?,workspace_path=?,current_session_id=?,session_generation=session_generation+?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND session_generation=? AND tombstone_at IS NULL').run(next.enabled, next.name, next.prompt, next.provider, next.model, next.agent, next.variant, next.mode, next.workspacePath, created?.sessionID ?? row.current_session_id, workspaceChanged ? 1 : 0, now(), assistantID, input.expectedRevision, row.session_generation); if (!result.changes) fail('revision_conflict'); bump(); return output(assistant(assistantID)); };
830
- const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await client().session.summarize({ sessionID: target.current_session_id, directory: effectiveWorkspace(target), providerID: target.provider_id, modelID: target.model_id }); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await client().session.summarize({ sessionID: target.current_session_id, directory: effectiveWorkspace(target), providerID: target.provider_id, modelID: target.model_id }); } if (result.error || result.data !== true) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
1137
+ const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); } if (result?.error || (result?.data !== true && result?.id == null && result?.type !== 'compaction')) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
831
1138
  const sendWithConfig = async ({ row, sessionID, directory, config, parts, messageID, restore }) => {
832
- const sendPrompt = (targetSessionID, targetDirectory, targetConfig) => client().session.promptAsync({ sessionID: targetSessionID, directory: targetDirectory, ...targetConfig, parts, messageID });
833
- let result = await sendPrompt(sessionID, directory, config);
1139
+ const sendPrompt = async (targetSessionID, targetConfig) => invokeSession(async () => {
1140
+ const api = client();
1141
+ if (typeof api.session.switchAgent === 'function' && targetConfig?.agent) await api.session.switchAgent({ sessionID: targetSessionID, agent: targetConfig.agent });
1142
+ if (typeof api.session.switchModel === 'function' && targetConfig?.model) await api.session.switchModel({ sessionID: targetSessionID, model: { id: targetConfig.model.modelID, providerID: targetConfig.model.providerID, ...(targetConfig.variant ? { variant: targetConfig.variant } : {}) } });
1143
+ if (typeof api.session.instructions?.entry?.put === 'function' && typeof targetConfig?.system === 'string' && targetConfig.system) await api.session.instructions.entry.put({ sessionID: targetSessionID, key: 'system', value: targetConfig.system });
1144
+ return api.session.prompt(toV2PromptInput(targetSessionID, parts, messageID));
1145
+ });
1146
+ let result = await sendPrompt(sessionID, config);
834
1147
  if (isMissing(result) && restore) {
835
1148
  const restored = await restoreOnce(row, sessionID, row.session_generation);
836
1149
  const target = active(row.assistant_id);
837
1150
  const targetConfig = configuration(target);
838
- result = await sendPrompt(restored.sessionID, restored.directory, targetConfig);
1151
+ result = await sendPrompt(restored.sessionID, targetConfig);
839
1152
  if (promptAdmitted(result)) mirrorAdmittedUserMessage(target, restored.sessionID, messageID, parts, targetConfig);
840
1153
  return { result, binding: restored };
841
1154
  }
@@ -1012,12 +1325,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1012
1325
  parts: [card],
1013
1326
  });
1014
1327
  if (card.cardType === 'session' && card.sessionID) {
1015
- upsertContactWatch(db, {
1328
+ const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
1329
+ || (nonEmptyString(card.messageID) ? card.messageID : null);
1330
+ if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
1331
+ upsertContactWatch(db, {
1016
1332
  assistantID,
1017
1333
  sessionID: card.sessionID,
1018
1334
  directory: card.directory,
1019
1335
  status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1020
1336
  updatedAt: now(),
1337
+ ...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
1021
1338
  });
1022
1339
  }
1023
1340
  bump();
@@ -1115,12 +1432,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1115
1432
  });
1116
1433
  ordinal += 1;
1117
1434
  if (card.cardType === 'session' && card.sessionID) {
1118
- upsertContactWatch(db, {
1435
+ const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
1436
+ || (nonEmptyString(card.messageID) ? card.messageID : null);
1437
+ if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
1438
+ upsertContactWatch(db, {
1119
1439
  assistantID,
1120
1440
  sessionID: card.sessionID,
1121
1441
  directory: card.directory,
1122
1442
  status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1123
1443
  updatedAt: now(),
1444
+ ...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
1124
1445
  });
1125
1446
  }
1126
1447
  });
@@ -1259,16 +1580,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1259
1580
  };
1260
1581
  const v2 = await viaExact(api?.v2?.session?.message, api?.v2?.session);
1261
1582
  if (v2) return v2;
1583
+ // Official 2.x: session.message.get; shim may also expose callable session.message.
1584
+ const nestedGet = await viaExact(api?.session?.message?.get, api?.session?.message);
1585
+ if (nestedGet) return nestedGet;
1262
1586
  const legacy = await viaExact(api?.session?.message, api?.session);
1263
1587
  if (legacy) return legacy;
1264
1588
  try {
1265
- const listed = await api.session.messages({
1589
+ const listed = await api.message.list({
1266
1590
  sessionID,
1267
- ...(directory ? { directory } : {}),
1268
1591
  limit: 100,
1592
+ order: 'desc',
1269
1593
  }, options);
1270
1594
  if (listed?.error) return { found: false };
1271
- const rows = Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []);
1595
+ const rows = projectionEntries(listed, sessionID) ?? (Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []));
1272
1596
  const hit = rows.some((row) => identity(row) === messageID);
1273
1597
  return { found: hit };
1274
1598
  } catch {
@@ -1295,14 +1619,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1295
1619
  const fromSession = extractAssignSessionModel({ session: sessionPayload });
1296
1620
  if (fromSession) return fromSession;
1297
1621
  try {
1298
- if (typeof api.session.messages !== 'function') return null;
1299
- const listed = await api.session.messages({
1622
+ if (typeof api.message?.list !== 'function') return null;
1623
+ const listed = await api.message.list({
1300
1624
  sessionID,
1301
- ...(dir ? { directory: dir } : {}),
1302
1625
  limit: 40,
1626
+ order: 'desc',
1303
1627
  }, options);
1304
1628
  if (listed?.error) return null;
1305
- messages = listed?.data ?? listed;
1629
+ messages = projectionEntries(listed, sessionID) ?? listed?.data ?? listed;
1306
1630
  } catch {
1307
1631
  return null;
1308
1632
  }
@@ -1456,12 +1780,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1456
1780
  }
1457
1781
 
1458
1782
  let messagesResult = null;
1459
- if (includeMessages && typeof client().session.messages === 'function') {
1783
+ if (includeMessages && typeof client().message?.list === 'function') {
1460
1784
  try {
1461
- messagesResult = await client().session.messages({
1785
+ messagesResult = await client().message.list({
1462
1786
  sessionID,
1463
- directory,
1464
1787
  limit: 100,
1788
+ order: 'desc',
1465
1789
  }, options);
1466
1790
  if (messagesResult?.error && !isMissing(messagesResult)) {
1467
1791
  // Message fetch failure must not invent status; keep messages null.
@@ -1499,10 +1823,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1499
1823
  const { signal } = params;
1500
1824
  signal?.throwIfAborted();
1501
1825
  const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
1502
- const result = await client().session.messages({
1503
- sessionID: resolved.sessionID, directory: resolved.directory, limit,
1504
- ...(params.before ? { before: params.before } : {}),
1505
- }, signal ? { signal } : undefined);
1826
+ const result = await invokeSession(() => client().message.list({
1827
+ sessionID: resolved.sessionID, limit, order: 'desc',
1828
+ ...(params.before ? { cursor: params.before } : {}),
1829
+ }));
1506
1830
  signal?.throwIfAborted();
1507
1831
  if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
1508
1832
  if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
@@ -1614,7 +1938,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1614
1938
  }
1615
1939
  // Worker model is prompt-only. Never mutate the contact assistant row.
1616
1940
  // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
1617
- return assignSession({
1941
+ const assigned = await assignSession({
1618
1942
  ...params,
1619
1943
  ...(reuseScope ? {
1620
1944
  sessionID: reuseScope.sessionID,
@@ -1641,16 +1965,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1641
1965
  allowedRoots: getAllowedRoots(),
1642
1966
  managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
1643
1967
  listWorktrees,
1644
- createSession: (created) => client().session.create(created, sdkOptions),
1645
- promptExisting: (prompted) => client().session.promptAsync(prompted, sdkOptions),
1968
+ createSession: (created) => client().session.create({
1969
+ title: created?.title,
1970
+ ...(created?.directory ? { location: { directory: created.directory } } : {}),
1971
+ ...(created?.agent ? { agent: created.agent } : {}),
1972
+ ...(created?.model ? { model: created.model } : {}),
1973
+ ...(created?.metadata ? { metadata: created.metadata } : {}),
1974
+ }),
1975
+ promptExisting: async (prompted) => {
1976
+ const api = client();
1977
+ if (typeof api.session.switchAgent === 'function' && prompted.agent) {
1978
+ await api.session.switchAgent({ sessionID: prompted.sessionID, agent: prompted.agent });
1979
+ }
1980
+ if (typeof api.session.switchModel === 'function' && prompted.model) {
1981
+ await api.session.switchModel({
1982
+ sessionID: prompted.sessionID,
1983
+ model: {
1984
+ id: prompted.model.modelID,
1985
+ providerID: prompted.model.providerID,
1986
+ ...(prompted.variant ? { variant: prompted.variant } : {}),
1987
+ },
1988
+ });
1989
+ }
1990
+ return api.session.prompt(toV2PromptInput(prompted.sessionID, prompted.parts, prompted.messageID));
1991
+ },
1646
1992
  deleteSession: (deleted) => (
1647
- typeof client().session.delete === 'function'
1648
- ? client().session.delete(deleted, sdkOptions)
1993
+ typeof client().session.remove === 'function'
1994
+ ? client().session.remove({ sessionID: deleted?.sessionID, ...(deleted?.directory ? {} : {}) })
1649
1995
  : undefined
1650
1996
  ),
1651
1997
  lookupMessage: (lookup) => lookupAssignMessage({ ...lookup, signal }),
1652
1998
  messageID: params?.messageID || `msg_assign_${id()}`,
1653
1999
  });
2000
+ // Stash until the session card watch is written so reuse cannot settle on an old tail.
2001
+ if (nonEmptyString(assigned?.sessionID) && nonEmptyString(assigned?.messageID)) {
2002
+ pendingAssignMessageIDs.set(assigned.sessionID, assigned.messageID);
2003
+ }
2004
+ return assigned;
1654
2005
  };
1655
2006
  /** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
1656
2007
  const watchSessionWork = async (_row, params = {}) => {
@@ -1675,7 +2026,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1675
2026
  reused: true,
1676
2027
  };
1677
2028
  };
1678
- /** Abort an existing coding session via real OpenCode session.abort. */
2029
+ /** Abort an existing coding session via real OpenCode session.interrupt. */
1679
2030
  const stopSessionWork = async (_row, params = {}) => {
1680
2031
  const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1681
2032
  if (!sessionID) {
@@ -1690,8 +2041,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1690
2041
  });
1691
2042
  const signal = params.signal;
1692
2043
  signal?.throwIfAborted();
1693
- if (typeof client().session.abort !== 'function') {
1694
- throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
2044
+ if (typeof client().session.interrupt !== 'function') {
2045
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session interrupt is unavailable.');
1695
2046
  }
1696
2047
  const targetID = resolved.sessionID;
1697
2048
  // Share one abort lifecycle per worker session (register before await abort).
@@ -1719,10 +2070,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1719
2070
  let result;
1720
2071
  try {
1721
2072
  signal?.throwIfAborted();
1722
- result = await client().session.abort({
2073
+ result = await invokeSession(() => client().session.interrupt({
1723
2074
  sessionID: targetID,
1724
- directory: resolved.directory,
1725
- }, sdkOptions);
2075
+ }));
1726
2076
  } catch (error) {
1727
2077
  const observed = entry.observed;
1728
2078
  if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
@@ -1784,27 +2134,42 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1784
2134
  }
1785
2135
  const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
1786
2136
  params.signal?.throwIfAborted();
1787
- const session = client().session;
1788
- const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
1789
- if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
1790
- const request = { sessionID, directory: resolved.directory };
1791
- if (operation === 'steer') Object.assign(request, {
1792
- messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
1793
- });
1794
- if (operation === 'archive') request.time = { archived: now() };
2137
+ const api = client();
1795
2138
  let result;
1796
2139
  try {
1797
- result = await session[method](request, params.signal ? { signal: params.signal } : undefined);
2140
+ if (operation === 'steer') {
2141
+ const messageID = `msg_steer_${id()}`;
2142
+ const parts = [{ type: 'text', text: params.text.trim() }];
2143
+ result = await invokeSession(() => api.session.prompt(toV2PromptInput(sessionID, parts, messageID)));
2144
+ if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
2145
+ if (result?.error || !promptAdmitted(result)) {
2146
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
2147
+ }
2148
+ return { sessionID, directory: resolved.directory, operation, messageID, admitted: true };
2149
+ }
2150
+ if (operation === 'archive') {
2151
+ if (typeof api.session.update !== 'function') {
2152
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session archive is unavailable.');
2153
+ }
2154
+ result = await invokeSession(() => api.session.update({ sessionID, directory: resolved.directory, time: { archived: now() } }));
2155
+ } else if (operation === 'delete') {
2156
+ if (typeof api.session.remove !== 'function') {
2157
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session delete is unavailable.');
2158
+ }
2159
+ result = await invokeSession(() => api.session.remove({ sessionID }));
2160
+ } else {
2161
+ throw new AssignError(ASSIGN_CODES.VALIDATION, `Unknown session operation ${operation}.`);
2162
+ }
1798
2163
  } catch (error) {
2164
+ if (error instanceof AssignError) throw error;
1799
2165
  throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
1800
2166
  }
1801
2167
  if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
1802
- if (result?.error || result?.data === false || (operation === 'steer' && !promptAdmitted(result))) {
1803
- // Do not retry an ambiguous prompt submission: it may already be admitted upstream.
2168
+ if (result?.error || result?.data === false) {
1804
2169
  throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
1805
2170
  }
1806
- if (operation !== 'steer') reportAssignedSession(sessionID, 'cancelled');
1807
- return { sessionID, directory: resolved.directory, operation, ...(operation === 'steer' ? { messageID: request.messageID, admitted: true } : { [operation === 'archive' ? 'archived' : 'deleted']: true }) };
2171
+ reportAssignedSession(sessionID, 'cancelled', false);
2172
+ return { sessionID, directory: resolved.directory, operation, ...(operation === 'archive' ? { archived: true } : { deleted: true }) };
1808
2173
  };
1809
2174
  const matchRegisteredProject = async (directory) => {
1810
2175
  const projects = await listProjects();
@@ -2192,11 +2557,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2192
2557
  try { return effectiveWorkspace(live); } catch { return null; }
2193
2558
  })();
2194
2559
  if (resolvedDirectory) {
2195
- const messagesResult = await client().session.messages({
2560
+ const messagesResult = await invokeSession(() => client().message.list({
2196
2561
  sessionID,
2197
- directory: resolvedDirectory,
2198
2562
  limit: 100,
2199
- });
2563
+ order: 'desc',
2564
+ }));
2200
2565
  if (!messagesResult?.error) {
2201
2566
  const rows = Array.isArray(messagesResult?.data)
2202
2567
  ? messagesResult.data
@@ -2959,12 +3324,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2959
3324
  parts: [card],
2960
3325
  });
2961
3326
  if (card.cardType === 'session' && card.sessionID) {
3327
+ const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
3328
+ || (nonEmptyString(card.messageID) ? card.messageID : null);
3329
+ if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
2962
3330
  upsertContactWatch(db, {
2963
3331
  assistantID: row.assistant_id,
2964
3332
  sessionID: card.sessionID,
2965
3333
  directory: card.directory,
2966
3334
  status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
2967
3335
  updatedAt: now(),
3336
+ ...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
2968
3337
  });
2969
3338
  }
2970
3339
  bump();
@@ -2977,7 +3346,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2977
3346
  };
2978
3347
  /**
2979
3348
  * Read-only inter-assistant DM. Inserts into the recipient's OpenChamber
2980
- * contact transcript only. Must never call OpenCode promptAsync, mutate
3349
+ * contact transcript only. Must never call OpenCode session.prompt, mutate
2981
3350
  * sessions/files/worktrees, or run tools on the recipient's behalf.
2982
3351
  *
2983
3352
  * Assign opens a worker session on the sender's contact turn — never here.
@@ -3068,17 +3437,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3068
3437
  db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
3069
3438
  if (interrupted) return { binding: binding(row), aborted: true };
3070
3439
  if (!row.current_session_id) fail('revision_conflict');
3071
- const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) });
3440
+ const result = await invokeSession(() => client().session.interrupt({ sessionID: row.current_session_id }));
3072
3441
  if (isMissing(result)) fail('not_found');
3073
- if (result.error) fail('upstream_error');
3442
+ if (result?.error) fail('upstream_error');
3074
3443
  return { binding: binding(row), aborted: true };
3075
3444
  };
3076
3445
  const createNew = async (assistantID) => { for (let attempt = 0; attempt < 3; attempt++) { const row = active(assistantID); const created = await createSession(row); const won = replaceBinding(row, created); if (won) return binding(won); } return binding(active(assistantID)); };
3077
3446
  const shareOperation = (operationID) => { const row = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); return row && { operationID: row.operation_id, assistantID: row.assistant_id, sessionID: row.session_id, messageID: row.message_id, state: row.state, phase: row.phase, attempt: row.attempt, leaseExpiresAt: row.lease_expires_at, errorCode: row.error_code }; };
3078
3447
  const claim = (operationID, retry = false) => { db.exec('BEGIN IMMEDIATE'); try { const operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (!operation) { db.exec('COMMIT'); return null; } const at = now(); const eligible = operation.state === 'failed' ? retry : operation.state === 'running' && operation.lease_expires_at <= at && operation.phase === 'admitted'; if (!eligible || operation.attempt >= SHARE_MAX_ATTEMPTS) { db.exec('COMMIT'); return null; } const result = db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitting',attempt=attempt+1,lease_expires_at=?,error_code=NULL,updated_at=? WHERE operation_id=? AND state=? AND phase=? AND attempt<? AND (state='failed' OR lease_expires_at<=?)").run(at + SHARE_LEASE_MS, at, operationID, operation.state, operation.phase, SHARE_MAX_ATTEMPTS, at); const claimed = result.changes ? db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID) : null; db.exec('COMMIT'); return claimed; } catch (error) { db.exec('ROLLBACK'); throw error; } };
3079
3448
  const completeOrFail = (operation, errorCode = null) => { const at = now(); if (errorCode) db.prepare("UPDATE assistant_share_operation SET state='failed',phase='admitted',error_code=?,lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(errorCode, at, operation.operation_id); else db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitted',error_code=NULL,lease_expires_at=?,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(at + SHARE_LEASE_MS, at, operation.operation_id); };
3080
- const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const result = await client().session.promptAsync({ sessionID: operation.session_id, directory: effectiveWorkspace(row), ...config, parts: payload.parts, messageID: operation.message_id }); if (!promptAdmitted(result)) fail('upstream_error'); mirrorAdmittedUserMessage(row, operation.session_id, operation.message_id, payload.parts, config); completeOrFail(operation); } catch (error) { completeOrFail(operation, error instanceof AssistantError ? error.code : 'upstream_error'); } };
3081
- const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await client().session.messages({ sessionID: operation.session_id, directory: effectiveWorkspace(row), limit: 100 }); const messages = result.data ?? []; const found = Array.isArray(messages) && messages.some((message) => message?.info?.id === operation.message_id || message?.id === operation.message_id); if (found) db.prepare("UPDATE assistant_share_operation SET state='completed',phase='submitted',lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now() && operation.attempt >= SHARE_MAX_ATTEMPTS) db.prepare("UPDATE assistant_share_operation SET state='failed',lease_expires_at=NULL,error_code='attempt_limit',updated_at=? WHERE operation_id=? AND state='running' AND phase='admitted'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now()) { const claimed = claim(operation.operation_id); if (claimed) void submitClaim(claimed); } else if (operation.phase === 'submitted' && operation.lease_expires_at <= now()) db.prepare("UPDATE assistant_share_operation SET state='unresolved',lease_expires_at=NULL,error_code='message_unresolved',updated_at=? WHERE operation_id=? AND state='running' AND phase='submitted'").run(now(), operation.operation_id); } catch { /* Reconciliation remains retryable until its lease expires. */ } } };
3449
+ const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const sent = await sendWithConfig({ row, sessionID: operation.session_id, directory: effectiveWorkspace(row), config, parts: payload.parts, messageID: operation.message_id, restore: false }); if (!promptAdmitted(sent.result)) fail('upstream_error'); completeOrFail(operation); } catch (error) { completeOrFail(operation, error instanceof AssistantError ? error.code : 'upstream_error'); } };
3450
+ const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await invokeSession(() => client().message.list({ sessionID: operation.session_id, limit: 100, order: 'desc' })); if (result?.error) continue; const messages = projectionEntries(result, operation.session_id) ?? result.data ?? []; const found = Array.isArray(messages) && messages.some((message) => message?.info?.id === operation.message_id || message?.id === operation.message_id); if (found) db.prepare("UPDATE assistant_share_operation SET state='completed',phase='submitted',lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now() && operation.attempt >= SHARE_MAX_ATTEMPTS) db.prepare("UPDATE assistant_share_operation SET state='failed',lease_expires_at=NULL,error_code='attempt_limit',updated_at=? WHERE operation_id=? AND state='running' AND phase='admitted'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now()) { const claimed = claim(operation.operation_id); if (claimed) void submitClaim(claimed); } else if (operation.phase === 'submitted' && operation.lease_expires_at <= now()) db.prepare("UPDATE assistant_share_operation SET state='unresolved',lease_expires_at=NULL,error_code='message_unresolved',updated_at=? WHERE operation_id=? AND state='running' AND phase='submitted'").run(now(), operation.operation_id); } catch { /* Reconciliation remains retryable until its lease expires. */ } } };
3082
3451
  const reconcile = async () => {
3083
3452
  if (closed) return;
3084
3453
  try {
@@ -3154,7 +3523,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3154
3523
  )),
3155
3524
  };
3156
3525
  };
3157
- return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
3526
+ return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: false, sessionMetadataAvailable: false }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
3158
3527
  db.exec('BEGIN IMMEDIATE');
3159
3528
  try {
3160
3529
  const result = db.prepare('UPDATE assistant_v2 SET tombstone_at=?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND tombstone_at IS NULL').run(now(), now(), assistantID, expectedRevision);
@@ -3178,6 +3547,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
3178
3547
  }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, markContactRead, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
3179
3548
  cancelAssignedResumes({});
3180
3549
  sessionAbortInflight.clear();
3550
+ pendingAssignMessageIDs.clear();
3181
3551
  for (const turn of contactControllers.values()) turn.controller.abort();
3182
3552
  if (!closed) {
3183
3553
  closed = true;