@openchambery/web 1.19.7-beta.2 → 1.19.7-beta.21

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 (187) hide show
  1. package/dist/assets/AgentAvatar-DI2zs2WH.js +1 -0
  2. package/dist/assets/AssistantView-sV1PVQiM.js +2 -0
  3. package/dist/assets/DiagramView-D2hqP5VV.js +1 -0
  4. package/dist/assets/FadeInOnReveal-BWJcUp5P.js +1 -0
  5. package/dist/assets/Icon-4My8YZwt.js +1 -0
  6. package/dist/assets/MarkdownLoadingSkeleton-Vezjvk9B.js +3 -0
  7. package/dist/assets/MarkdownRenderer-x091Z--3.js +2 -0
  8. package/dist/assets/MarkdownRendererImpl-DML8BSro.js +7 -0
  9. package/dist/assets/MarkstreamRendererImpl-BK-HM2k3.js +1 -0
  10. package/dist/assets/{MarkstreamRendererImpl-CR0fBG5w.css → MarkstreamRendererImpl-Ca7RATbs.css} +1 -1
  11. package/dist/assets/MobileDetailNavigation-DcxkWiks.js +1 -0
  12. package/dist/assets/MobileShareBridge-TAGcOiyb.js +4 -0
  13. package/dist/assets/MobileSurface-zqlZzEFQ.js +2 -0
  14. package/dist/assets/{MultiRunWindow-khpnWyZO.js → MultiRunWindow-iH1d9S14.js} +1 -1
  15. package/dist/assets/OnboardingScreen-BNEtPmvw.js +1 -0
  16. package/dist/assets/OverlayScrollbar-BZB21Yp_.js +2 -0
  17. package/dist/assets/SettingsView-CzW23DZA.js +25 -0
  18. package/dist/assets/{SettingsWindow-Dx5rU9v5.js → SettingsWindow-DZXCWZid.js} +1 -1
  19. package/dist/assets/TerminalView-pU0wpD8s.js +8 -0
  20. package/dist/assets/ToolOutputDialog-Bt7JChFG.js +10 -0
  21. package/dist/assets/appThemeRegistry-BI7WR2Wo.js +1 -0
  22. package/dist/assets/{button-z0GOBtWi.js → button-Or0daT9o.js} +1 -1
  23. package/dist/assets/clipboard-BXxLshVq.js +1 -0
  24. package/dist/assets/{collapsible-D9zJRpeB.js → collapsible-C-kzSGkq.js} +1 -1
  25. package/dist/assets/context-menu-Dpjla5p4.js +1 -0
  26. package/dist/assets/desktopHosts-BzSE0sK7.js +1 -0
  27. package/dist/assets/desktopRelayRestore-BaKjk1kQ.js +1 -0
  28. package/dist/assets/{diagram-CoEqmReY.js → diagram-D0mT3mo1.js} +1 -1
  29. package/dist/assets/{dialog-HCE4eLUW.js → dialog-BkhHBTd4.js} +1 -1
  30. package/dist/assets/dropdown-menu.styles-Bzo6L3FH.js +1 -0
  31. package/dist/assets/{es-D9KcV5TX.js → es-BHqnSjoZ.js} +1 -1
  32. package/dist/assets/fileQueries-DFY6Kiy1.js +1 -0
  33. package/dist/assets/fileReferenceActions-Di_DoDex.js +1 -0
  34. package/dist/assets/{fr-DPA9qUl3.js → fr-BfrlJY9i.js} +1 -1
  35. package/dist/assets/{gitApi-DNTMAa3F.js → gitApi-CeFszLvk.js} +3 -3
  36. package/dist/assets/{input-BG3VWTIF.js → input-BUsGnzwe.js} +1 -1
  37. package/dist/assets/insertionBoundaries-DDJNusn2.js +4 -0
  38. package/dist/assets/{ja-Dsif3NoD.js → ja-DP-7qt-x.js} +1 -1
  39. package/dist/assets/{ko-Dixx4Ctg.js → ko-E2B5vGQP.js} +1 -1
  40. package/dist/assets/main-CjnOsKDL.js +169 -0
  41. package/dist/assets/main-j-Ij13jc.js +2 -0
  42. package/dist/assets/markdown-worker-BuveaH3O.js +1 -0
  43. package/dist/assets/markdownTheme-CVS01wHf.js +2 -0
  44. package/dist/assets/miniChat-DgxavREE.js +2 -0
  45. package/dist/assets/mobile-C0rwPMPM.js +2 -0
  46. package/dist/assets/multirun-ot7-bKg7.js +4 -0
  47. package/dist/assets/outsideFileGrants-BB4GPvCq.js +1 -0
  48. package/dist/assets/path-utils-jGozTg9d.js +1 -0
  49. package/dist/assets/persistence-CSZp1CNB.js +3 -0
  50. package/dist/assets/{pl-Bj78ZGgf.js → pl-r8X5gRM5.js} +1 -1
  51. package/dist/assets/platform-CenlWqxu.js +2 -0
  52. package/dist/assets/projectMeta-3yOEc0oI.js +3 -0
  53. package/dist/assets/{pt-BR-CFL3xSAf.js → pt-BR-DcpdXCf5.js} +1 -1
  54. package/dist/assets/renderElectronMiniChatApp-BPNvFA_N.js +2 -0
  55. package/dist/assets/renderMobileApp-CNog29Xy.js +28 -0
  56. package/dist/assets/runtimeConfig-D28kNEYO.js +2 -0
  57. package/dist/assets/runtimeEndpointReset-CorvFIRk.js +147 -0
  58. package/dist/assets/sessionChangeSummary-CX2MJIkc.js +1 -0
  59. package/dist/assets/sessionLookup-CbatbQf7.js +1 -0
  60. package/dist/assets/sortable-tabs-strip-C5gGnikj.js +1 -0
  61. package/dist/assets/src-Af9cMFKF.css +2 -0
  62. package/dist/assets/{store-DzpbXWX1.js → store-BDFdnk6l.js} +2 -2
  63. package/dist/assets/terminalApi-CIIs9vFx.js +1 -0
  64. package/dist/assets/textarea-CIO34TZz.js +1 -0
  65. package/dist/assets/toolHelpers-DEvVP9U0.js +2 -0
  66. package/dist/assets/{tooltip-CtH61mQh.js → tooltip-BSrJYYYc.js} +1 -1
  67. package/dist/assets/{uk-BY60Ub3Z.js → uk-Di0vl9V-.js} +1 -1
  68. package/dist/assets/url-CE_gAfNa.js +1 -0
  69. package/dist/assets/{ChatContainer-CnOrdAgs.js → useAppFontEffects-CDpeZWK4.js} +44 -39
  70. package/dist/assets/useAssistantUIStore-BHacrxe_.js +1 -0
  71. package/dist/assets/useDesktopWindowControlsLayout-D-xPByG0.js +1 -0
  72. package/dist/assets/useEffectiveDirectory-Dp-tez_r.js +1 -0
  73. package/dist/assets/useFontPreferences-BxOHKuVw.js +1 -0
  74. package/dist/assets/useMobileNavigationStore-DE1Pe9A9.js +412 -0
  75. package/dist/assets/useSessionAutoCleanup-jNJ_WC38.js +1 -0
  76. package/dist/assets/useUIStore-CqqO11ZI.js +1 -0
  77. package/dist/assets/useWorkerHighlightedLines-MDnGj3yv.js +7 -0
  78. package/dist/assets/utils-B0mQXN9L.js +1 -0
  79. package/dist/assets/{vendor-markstream-react-CwvbEg9R.js → vendor-markstream-react-C3K8YH0d.js} +4 -4
  80. package/dist/assets/{zh-CN-Cox1LaOW.js → zh-CN-coFCfeQE.js} +1 -1
  81. package/dist/assets/{zh-TW-B6RU0mxa.js → zh-TW-oDRZ1-yb.js} +1 -1
  82. package/dist/index.html +9 -11
  83. package/dist/mini-chat.html +9 -11
  84. package/dist/mobile.html +15 -17
  85. package/package.json +2 -1
  86. package/server/index.js +2 -0
  87. package/server/lib/assistant-delivery-parts.js +3 -1
  88. package/server/lib/assistant-delivery-parts.test.js +11 -0
  89. package/server/lib/assistants/DOCUMENTATION.md +76 -3
  90. package/server/lib/assistants/assign.js +225 -0
  91. package/server/lib/assistants/assign.test.js +118 -0
  92. package/server/lib/assistants/bubbles.js +45 -0
  93. package/server/lib/assistants/bubbles.test.js +36 -0
  94. package/server/lib/assistants/cards.js +148 -0
  95. package/server/lib/assistants/cards.test.js +116 -0
  96. package/server/lib/assistants/contact-store.js +284 -0
  97. package/server/lib/assistants/contact-store.test.js +131 -0
  98. package/server/lib/assistants/contact-tools.js +761 -0
  99. package/server/lib/assistants/contact-tools.test.js +328 -0
  100. package/server/lib/assistants/contracts.js +20 -1
  101. package/server/lib/assistants/harness.js +551 -0
  102. package/server/lib/assistants/harness.test.js +607 -0
  103. package/server/lib/assistants/routes.js +44 -4
  104. package/server/lib/assistants/service.js +930 -17
  105. package/server/lib/assistants/service.test.js +1163 -45
  106. package/server/lib/llm/DOCUMENTATION.md +84 -0
  107. package/server/lib/llm/catalog.js +100 -0
  108. package/server/lib/llm/catalog.test.js +78 -0
  109. package/server/lib/llm/completions.js +151 -0
  110. package/server/lib/llm/completions.test.js +127 -0
  111. package/server/lib/llm/generate.js +570 -0
  112. package/server/lib/llm/generate.test.js +459 -0
  113. package/server/lib/llm/routes.js +47 -0
  114. package/server/lib/llm/temp-directory.js +37 -0
  115. package/server/lib/notifications/APNS.md +23 -17
  116. package/server/lib/notifications/DOCUMENTATION.md +5 -4
  117. package/server/lib/notifications/apns-runtime.js +139 -20
  118. package/server/lib/notifications/apns-runtime.test.js +66 -7
  119. package/server/lib/notifications/apns-titles.js +62 -62
  120. package/server/lib/notifications/apns-titles.test.js +3 -1
  121. package/server/lib/notifications/routes.js +1 -1
  122. package/server/lib/notifications/runtime.js +177 -86
  123. package/server/lib/notifications/runtime.test.js +122 -0
  124. package/server/lib/opencode/feature-routes-runtime.js +30 -0
  125. package/server/lib/opencode/feature-routes-runtime.test.js +20 -0
  126. package/server/lib/opencode/settings-runtime.js +18 -2
  127. package/server/lib/session-goal/DOCUMENTATION.md +8 -1
  128. package/server/lib/session-goal/runtime.js +20 -0
  129. package/server/lib/session-index/DOCUMENTATION.md +3 -1
  130. package/server/lib/session-index/service.js +10 -4
  131. package/server/lib/session-index/service.test.js +37 -0
  132. package/server/lib/session-title/runtime.js +1 -0
  133. package/server/lib/session-title/runtime.test.js +11 -0
  134. package/dist/assets/AgentAvatar-ziUs4sw3.js +0 -1
  135. package/dist/assets/AssistantView-DF4yXFgS.js +0 -1
  136. package/dist/assets/ContextPanel-Cg_uCHzJ.js +0 -285
  137. package/dist/assets/DiagramView-DYh3Zn2y.js +0 -1
  138. package/dist/assets/FadeInOnReveal-BAlsRFJH.js +0 -1
  139. package/dist/assets/Icon-CLpn1mIz.js +0 -1
  140. package/dist/assets/MarkdownRendererImpl-QzUEpBEm.js +0 -7
  141. package/dist/assets/MarkstreamRendererImpl-CXrfeUia.js +0 -1
  142. package/dist/assets/MobileDetailNavigation-DJXEYfq4.js +0 -1
  143. package/dist/assets/MobileShareBridge-CliVo1YH.js +0 -4
  144. package/dist/assets/MobileSurface-9Xh6WQkY.js +0 -2
  145. package/dist/assets/OnboardingScreen-CJYyR54B.js +0 -1
  146. package/dist/assets/OverlayScrollbar-C-jZ8ITG.js +0 -2
  147. package/dist/assets/SettingsView-DyCZehFG.js +0 -25
  148. package/dist/assets/TerminalView-XSALfUaH.js +0 -8
  149. package/dist/assets/ToolOutputDialog-BfuXazW9.js +0 -10
  150. package/dist/assets/appThemeRegistry-DDWrniMM.js +0 -2
  151. package/dist/assets/clipboard-Bz0Zm9VJ.js +0 -1
  152. package/dist/assets/desktop-CfLlmafK.js +0 -1
  153. package/dist/assets/desktopHosts-C1Ns3DUu.js +0 -1
  154. package/dist/assets/desktopRelayRestore-LBt0CBOj.js +0 -1
  155. package/dist/assets/fileQueries-Dnn15WsA.js +0 -1
  156. package/dist/assets/insertionBoundaries-DMxuUnL_.js +0 -4
  157. package/dist/assets/main-B9HVtgXE.js +0 -2
  158. package/dist/assets/main-ZNyIs3Sn.js +0 -29
  159. package/dist/assets/markdown-worker-y3ewaqAK.js +0 -4
  160. package/dist/assets/markdownTheme-TdhQOZSN.js +0 -2
  161. package/dist/assets/miniChat-q0T0WGkZ.js +0 -2
  162. package/dist/assets/mobile-DEduvLJj.js +0 -2
  163. package/dist/assets/multirun-DU-wrxlG.js +0 -4
  164. package/dist/assets/persistence-CmjKImz2.js +0 -3
  165. package/dist/assets/platform-skqcMARt.js +0 -2
  166. package/dist/assets/projectMeta-DNWx6Uva.js +0 -3
  167. package/dist/assets/radio-BEonIX1k.js +0 -1
  168. package/dist/assets/renderElectronMiniChatApp--JYWY41L.js +0 -2
  169. package/dist/assets/renderMobileApp-B0Ix_uNo.js +0 -25
  170. package/dist/assets/runtime-switch-BkpTTbEi.js +0 -1
  171. package/dist/assets/runtimeConfig-BTDIG7mX.js +0 -2
  172. package/dist/assets/runtimeEndpointReset-CZQCYtVI.js +0 -4
  173. package/dist/assets/shikiHighlight-C9Cx56Qg.js +0 -1
  174. package/dist/assets/sortable-tabs-strip-Bj8N0VWf.js +0 -1
  175. package/dist/assets/src-2eFAiBLq.css +0 -2
  176. package/dist/assets/terminalApi-CNqqwjfG.js +0 -1
  177. package/dist/assets/url-KeihbE0d.js +0 -1
  178. package/dist/assets/useAppFontEffects-CKLSamae.js +0 -7
  179. package/dist/assets/useAssistantUIStore-DaplPyb0.js +0 -1
  180. package/dist/assets/useDesktopWindowControlsLayout-DJSkFfhT.js +0 -1
  181. package/dist/assets/useEffectiveDirectory-B3rbY1Kv.js +0 -1
  182. package/dist/assets/useFontPreferences-D8emhcNv.js +0 -1
  183. package/dist/assets/useMobileNavigationStore-CAvVUItx.js +0 -412
  184. package/dist/assets/useSessionAutoCleanup-DxNgqJ8s.js +0 -1
  185. package/dist/assets/useUIStore-B7KJS5G7.js +0 -1
  186. package/dist/assets/useWorkerHighlightedLines-DiDF68LR.js +0 -7
  187. package/dist/assets/utils-CTL16t2f.js +0 -1
@@ -5,9 +5,51 @@ import path from 'node:path';
5
5
  import { createOpencodeClient } from '@opencode-ai/sdk/v2';
6
6
  import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
7
7
  import { reduceBackfillState } from './history-state.js';
8
+ import { getWorktrees as defaultListWorktrees } from '../git/service.js';
9
+ import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
10
+ import {
11
+ CONTACT_SETTLE_TEXT,
12
+ contactHistoryForLlm,
13
+ deleteContactMessages,
14
+ ensureContactSchema,
15
+ insertContactMessage,
16
+ listContactMessages,
17
+ listInFlightWatches,
18
+ listWatchesBySession,
19
+ nextContactOrdinal,
20
+ updateSessionCardStatus,
21
+ upsertContactWatch,
22
+ } from './contact-store.js';
23
+ import {
24
+ ASSIGNED_SESSION_FALLBACK_BUBBLE,
25
+ boundSessionListLimit,
26
+ confirmBubbleAfterContactReset,
27
+ createContactTools,
28
+ filterRegisteredProjects,
29
+ matchesProjectQuery,
30
+ normalizeRegisteredProjects,
31
+ sanitizeRegisteredProject,
32
+ } from './contact-tools.js';
33
+ import { AssignError, ASSIGN_CODES, assignSession, resolveAssignDirectory } from './assign.js';
34
+ import { runContactTurn as defaultRunContactTurn } from './harness.js';
8
35
 
9
36
  const require = createRequire(import.meta.url);
10
37
  const SCHEMA_VERSION = 11;
38
+ const normalizeSessionDirectory = (value) => {
39
+ if (typeof value !== 'string') return null;
40
+ const trimmed = value.trim();
41
+ if (!trimmed) return null;
42
+ let resolved = trimmed;
43
+ try { resolved = fs.realpathSync(trimmed); } catch { /* Keep the original path when realpath is unavailable. */ }
44
+ const normalized = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
45
+ return normalized || null;
46
+ };
47
+ const directoryContained = (candidate, root) => {
48
+ const left = normalizeSessionDirectory(candidate);
49
+ const right = normalizeSessionDirectory(root);
50
+ if (!left || !right) return false;
51
+ return left === right || left.startsWith(`${right}/`);
52
+ };
11
53
  const BACKFILL_PAGE_SIZE = 100;
12
54
  const BACKFILL_MAX_PAGES = 3;
13
55
  const BACKFILL_MESSAGES_ATTEMPTS = 3;
@@ -20,8 +62,13 @@ const json = (value) => JSON.stringify(value);
20
62
  const parse = (value) => JSON.parse(value);
21
63
  const hash = (value) => crypto.createHash('sha256').update(json(value)).digest('hex');
22
64
  const id = () => crypto.randomUUID();
23
- export class AssistantError extends Error { constructor(code) { super(code); this.code = code; } }
24
- const fail = (code) => { throw new AssistantError(code); };
65
+ export class AssistantError extends Error {
66
+ constructor(code, message) {
67
+ super(typeof message === 'string' && message.trim() ? message.trim() : code);
68
+ this.code = code;
69
+ }
70
+ }
71
+ const fail = (code, message) => { throw new AssistantError(code, message); };
25
72
  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(); };
26
73
  const nonEmptyString = (value, max = 10_000) => typeof value === 'string' && value.length > 0 && value.length <= max;
27
74
  const isMissing = (result) => result?.error?.status === 404 || result?.error?.statusCode === 404 || result?.error?.code === 'not_found' || result?.status === 404;
@@ -41,7 +88,7 @@ const isTransientMessagesFailure = (result, error) => {
41
88
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
42
89
  const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
43
90
 
44
- export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], globalEventHub = null, onRevisionTip = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, reconcileIntervalMs = 60_000, clientFactory } = {}) => {
91
+ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], 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 } = {}) => {
45
92
  if (!dbPath || !dataDir) return null;
46
93
  const Database = require('better-sqlite3');
47
94
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -61,7 +108,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
61
108
  CREATE INDEX IF NOT EXISTS assistant_message_mirror_page ON assistant_message_mirror(assistant_id, session_id, ordinal, message_id);
62
109
  CREATE TABLE IF NOT EXISTS assistant_message_part_mirror (assistant_id TEXT NOT NULL, session_id TEXT NOT NULL, message_id TEXT NOT NULL, part_id TEXT NOT NULL, part_json TEXT NOT NULL, ordinal INTEGER NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (assistant_id, session_id, message_id, part_id));
63
110
  CREATE INDEX IF NOT EXISTS assistant_message_part_mirror_message ON assistant_message_part_mirror(assistant_id, session_id, message_id, ordinal, part_id);
64
- CREATE TABLE IF NOT EXISTS assistant_message_backfill (assistant_id TEXT NOT NULL, session_id TEXT NOT NULL, cursor TEXT, complete INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (assistant_id, session_id));`);
111
+ CREATE TABLE IF NOT EXISTS assistant_message_backfill (assistant_id TEXT NOT NULL, session_id TEXT NOT NULL, cursor TEXT, complete INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (assistant_id, session_id));
112
+ CREATE TABLE IF NOT EXISTS assistant_scheduled_task (
113
+ assistant_id TEXT NOT NULL,
114
+ project_id TEXT NOT NULL,
115
+ task_id TEXT NOT NULL,
116
+ created_at INTEGER NOT NULL,
117
+ PRIMARY KEY (assistant_id, project_id, task_id)
118
+ );
119
+ CREATE INDEX IF NOT EXISTS assistant_scheduled_task_assistant
120
+ ON assistant_scheduled_task(assistant_id, created_at DESC);`);
121
+ ensureContactSchema(db);
65
122
  const historyColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_session_history')").all().map((column) => column.name));
66
123
  if (!historyColumns.has('directory')) db.exec('ALTER TABLE assistant_session_history ADD COLUMN directory TEXT');
67
124
  const mirrorColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_message_mirror')").all().map((column) => column.name));
@@ -106,7 +163,33 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
106
163
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
107
164
  db.prepare("UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND COALESCE(json_extract(info_json,'$.role'),'')<>'user' AND COALESCE(json_extract(info_json,'$.openchamberAssistantAdmission'),0)<>1").run(assistantID, sessionID);
108
165
  };
109
- const output = (row) => ({ id: row.assistant_id, revision: row.revision, enabled: Boolean(row.enabled), name: row.name, defaultPrompt: row.default_prompt, workspacePath: row.workspace_path, managedWorkspacePath: workspace(null, row.assistant_id, true), effectiveWorkspacePath: effectiveWorkspace(row), providerID: row.provider_id, modelID: row.model_id, agent: row.agent, variant: row.variant, mode: row.mode === 'stateless' ? 'stateless' : 'continuous', sessionID: row.current_session_id, sessionGeneration: row.session_generation, historySessionIDs: historyIDs(row.assistant_id), historySessionCount: historyCount(row.assistant_id), createdAt: row.created_at, updatedAt: row.updated_at, tombstoneAt: row.tombstone_at });
166
+ const output = (row) => {
167
+ const watches = listInFlightWatches(db, row.assistant_id);
168
+ return {
169
+ id: row.assistant_id,
170
+ revision: row.revision,
171
+ enabled: Boolean(row.enabled),
172
+ name: row.name,
173
+ defaultPrompt: row.default_prompt,
174
+ workspacePath: row.workspace_path,
175
+ managedWorkspacePath: workspace(null, row.assistant_id, true),
176
+ effectiveWorkspacePath: effectiveWorkspace(row),
177
+ providerID: row.provider_id,
178
+ modelID: row.model_id,
179
+ agent: row.agent,
180
+ variant: row.variant,
181
+ mode: row.mode === 'stateless' ? 'stateless' : 'continuous',
182
+ sessionID: row.current_session_id,
183
+ sessionGeneration: row.session_generation,
184
+ historySessionIDs: historyIDs(row.assistant_id),
185
+ historySessionCount: historyCount(row.assistant_id),
186
+ assignedSessionIDs: watches.map((watch) => watch.sessionID),
187
+ working: watches.some((watch) => watch.status === 'busy'),
188
+ createdAt: row.created_at,
189
+ updatedAt: row.updated_at,
190
+ tombstoneAt: row.tombstone_at,
191
+ };
192
+ };
110
193
  const binding = (row) => ({ sessionID: row.current_session_id, directory: effectiveWorkspace(row), sessionGeneration: row.session_generation });
111
194
  const client = () => clientFactory ? clientFactory() : createOpencodeClient({ baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''), headers: getOpenCodeAuthHeaders() });
112
195
  const metadata = (row) => ({ openchamber: { assistant: { assistantID: row.assistant_id, name: row.name } } });
@@ -235,6 +318,134 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
235
318
  db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID);
236
319
  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());
237
320
  };
321
+ const eventSessionID = (properties) => {
322
+ const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
323
+ return nonEmptyString(sessionID) ? sessionID : '';
324
+ };
325
+ const settleStatusFromEvent = (payload, properties) => {
326
+ if (payload.type === 'session.error') return 'error';
327
+ if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
328
+ if (payload.type === 'session.idle') return 'complete';
329
+ if (payload.type === 'session.status') {
330
+ const statusType = typeof properties.status?.type === 'string' ? properties.status.type : typeof properties.info?.type === 'string' ? properties.info.type : '';
331
+ if (statusType === 'busy' || statusType === 'retry') return 'busy';
332
+ if (statusType === 'idle') return 'complete';
333
+ }
334
+ return null;
335
+ };
336
+ const reportAssignedSession = (sessionID, status) => {
337
+ const watches = listWatchesBySession(db, sessionID);
338
+ if (watches.length === 0) return false;
339
+ let changed = false;
340
+ db.exec('BEGIN IMMEDIATE');
341
+ try {
342
+ for (const watch of watches) {
343
+ if (watch.status === status) continue;
344
+ // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
345
+ if (status === 'complete' && watch.status === 'error') continue;
346
+ updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
347
+ upsertContactWatch(db, {
348
+ assistantID: watch.assistantID,
349
+ sessionID,
350
+ directory: watch.directory,
351
+ status,
352
+ updatedAt: now(),
353
+ });
354
+ const settleText = CONTACT_SETTLE_TEXT[status];
355
+ const settleID = `settle_${watch.assistantID}_${sessionID}_${status}`;
356
+ if (settleText && !db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(settleID)) {
357
+ insertContactMessage(db, {
358
+ messageID: settleID,
359
+ assistantID: watch.assistantID,
360
+ role: 'assistant',
361
+ turnID: `settle:${sessionID}`,
362
+ bubbleIndex: 0,
363
+ createdAt: now(),
364
+ ordinal: nextContactOrdinal(db, watch.assistantID),
365
+ status: 'complete',
366
+ parts: [{ type: 'text', text: settleText }],
367
+ });
368
+ }
369
+ changed = true;
370
+ }
371
+ if (changed) bump();
372
+ db.exec('COMMIT');
373
+ } catch (error) {
374
+ db.exec('ROLLBACK');
375
+ throw error;
376
+ }
377
+ return changed;
378
+ };
379
+ const sessionStatusType = (session) => {
380
+ if (typeof session?.status?.type === 'string') return session.status.type;
381
+ if (typeof session?.status === 'string') return session.status;
382
+ if (typeof session?.type === 'string') return session.type;
383
+ return '';
384
+ };
385
+ const lastAssistantInfo = (messages) => {
386
+ if (!Array.isArray(messages)) return null;
387
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
388
+ const info = messages[index]?.info ?? messages[index];
389
+ if (info?.role === 'assistant') return info;
390
+ }
391
+ return null;
392
+ };
393
+ const inferAssignedSessionSettleStatus = (getResult, messagesResult) => {
394
+ if (isMissing(getResult)) return 'complete';
395
+ if (getResult?.error) return null;
396
+ const session = getResult?.data;
397
+ if (session?.error) return 'error';
398
+ const statusType = sessionStatusType(session);
399
+ if (statusType === 'busy' || statusType === 'retry') return null;
400
+ if (isMissing(messagesResult)) return 'complete';
401
+ const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
402
+ if (assistant?.error) return 'error';
403
+ if (statusType === 'idle' || session?.time?.completed) return 'complete';
404
+ if (assistant?.time?.completed) return 'complete';
405
+ return null;
406
+ };
407
+ const resolveWatchDirectory = (watch) => {
408
+ if (nonEmptyString(watch.directory)) {
409
+ try { return workspace(watch.directory, watch.assistantID); } catch { /* Fall through to the assistant workspace. */ }
410
+ }
411
+ const row = assistant(watch.assistantID);
412
+ if (!row || row.tombstone_at) return null;
413
+ try { return effectiveWorkspace(row); } catch { return null; }
414
+ };
415
+ const reconcileInFlightWatches = async () => {
416
+ if (closed) return;
417
+ let watches;
418
+ try {
419
+ watches = listInFlightWatches(db);
420
+ } catch {
421
+ return;
422
+ }
423
+ for (const watch of watches) {
424
+ if (closed) return;
425
+ try {
426
+ const directory = resolveWatchDirectory(watch);
427
+ if (!directory) continue;
428
+ let getResult;
429
+ try {
430
+ getResult = await client().session.get({ sessionID: watch.sessionID, directory });
431
+ } catch {
432
+ continue;
433
+ }
434
+ if (closed) return;
435
+ let messagesResult = null;
436
+ try {
437
+ messagesResult = await client().session.messages({ sessionID: watch.sessionID, directory, limit: 100 });
438
+ } catch {
439
+ messagesResult = null;
440
+ }
441
+ if (closed) return;
442
+ const status = inferAssignedSessionSettleStatus(getResult, messagesResult);
443
+ if (status) reportAssignedSession(watch.sessionID, status);
444
+ } catch {
445
+ // One failed watch must not block unrelated watches.
446
+ }
447
+ }
448
+ };
238
449
  const processEvent = (event) => {
239
450
  const payload = event?.payload?.payload ?? event?.payload ?? event;
240
451
  const properties = payload?.properties;
@@ -260,16 +471,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
260
471
  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;
261
472
  }
262
473
  if (payload.type === 'session.idle' || payload.type === 'session.error') {
263
- const sessionID = properties.sessionID;
264
- if (!nonEmptyString(sessionID)) return false;
265
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
474
+ const sessionID = eventSessionID(properties);
475
+ if (!sessionID) return false;
476
+ const reported = reportAssignedSession(sessionID, settleStatusFromEvent(payload, properties));
477
+ const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
478
+ return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
266
479
  }
267
480
  if (payload.type === 'session.status') {
268
- const sessionID = properties.sessionID;
269
- const statusType = typeof properties.status?.type === 'string' ? properties.status.type : typeof properties.info?.type === 'string' ? properties.info.type : '';
270
- if (!nonEmptyString(sessionID) || !nonEmptyString(statusType)) return false;
271
- if (statusType !== 'busy' && statusType !== 'retry' && statusType !== 'idle') return false;
272
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
481
+ const sessionID = eventSessionID(properties);
482
+ const status = settleStatusFromEvent(payload, properties);
483
+ if (!sessionID || !status) return false;
484
+ const reported = reportAssignedSession(sessionID, status);
485
+ const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); }
486
+ return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
487
+ }
488
+ if (payload.type === 'question.asked' || payload.type === 'permission.asked') {
489
+ const sessionID = eventSessionID(properties);
490
+ if (!sessionID) return false;
491
+ return reportAssignedSession(sessionID, 'question');
273
492
  }
274
493
  return false;
275
494
  };
@@ -407,7 +626,689 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
407
626
  if (promptAdmitted(result)) mirrorAdmittedUserMessage(row, sessionID, messageID, parts, config);
408
627
  return { result, binding: binding(row) };
409
628
  };
410
- const send = async (assistantID, input) => { if (!plainObject(input)) fail('validation_error'); validateParts(input.parts); const messageID = string(input.messageID, 256, true); const row = active(assistantID); if (row.mode !== 'stateless' && (input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation)) fail('revision_conflict'); const submit = async () => { const latest = active(assistantID); const target = await prepareExecutionBinding(latest); const sent = await sendWithConfig({ row: target, sessionID: target.current_session_id, directory: effectiveWorkspace(target), config: configuration(target), parts: input.parts, messageID, restore: target.mode !== 'stateless' }); if (!promptAdmitted(sent.result)) fail('upstream_error'); return { binding: sent.binding, messageID, admitted: true }; }; return row.mode === 'stateless' ? inStatelessLane(assistantID, submit) : submit(); };
629
+ const extractUserText = (input) => {
630
+ if (typeof input.text === 'string' && input.text.trim()) return input.text.trim();
631
+ if (!Array.isArray(input.parts)) return '';
632
+ const text = input.parts.filter((part) => part?.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('\n').trim();
633
+ if (text) return text;
634
+ return input.parts.some((part) => part?.type === 'file') ? '[attachment]' : '';
635
+ };
636
+ const userContactParts = (input, userText) => {
637
+ const parts = [];
638
+ if (Array.isArray(input.parts)) {
639
+ for (const part of input.parts) {
640
+ const parsed = parseContactPart(part);
641
+ if (parsed?.type === 'text' && parsed.text.trim()) parts.push({ type: 'text', text: parsed.text });
642
+ if (parsed?.type === 'file') parts.push(parsed);
643
+ }
644
+ }
645
+ if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
646
+ return parts;
647
+ };
648
+ /** Admit the user row only — HTTP 202 returns here; assistant bubbles persist later. */
649
+ const admitContactUser = (assistantID, { userMessageID, userText, userParts, turnID }) => {
650
+ db.exec('BEGIN IMMEDIATE');
651
+ try {
652
+ insertContactMessage(db, {
653
+ messageID: userMessageID,
654
+ assistantID,
655
+ role: 'user',
656
+ turnID,
657
+ bubbleIndex: 0,
658
+ createdAt: now(),
659
+ ordinal: nextContactOrdinal(db, assistantID),
660
+ status: 'complete',
661
+ parts: Array.isArray(userParts) && userParts.length > 0 ? userParts : [{ type: 'text', text: userText }],
662
+ });
663
+ bump();
664
+ db.exec('COMMIT');
665
+ } catch (error) {
666
+ db.exec('ROLLBACK');
667
+ throw error;
668
+ }
669
+ };
670
+ /** Persist assistant bubbles/cards for an already-admitted user turn (no user row). */
671
+ const persistContactAssistantReply = (assistantID, { userMessageID, bubbles, cards = [], turnID }) => {
672
+ db.exec('BEGIN IMMEDIATE');
673
+ try {
674
+ let ordinal = nextContactOrdinal(db, assistantID);
675
+ bubbles.forEach((text, index) => {
676
+ const bubbleID = `${userMessageID}:bubble:${index + 1}`;
677
+ insertContactMessage(db, {
678
+ messageID: bubbleID,
679
+ assistantID,
680
+ role: 'assistant',
681
+ turnID,
682
+ bubbleIndex: index,
683
+ createdAt: now(),
684
+ ordinal,
685
+ status: 'complete',
686
+ parts: [{ type: 'text', text }],
687
+ });
688
+ ordinal += 1;
689
+ });
690
+ cards.forEach((cardInput, index) => {
691
+ const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
692
+ if (!card) return;
693
+ insertContactMessage(db, {
694
+ messageID: `${userMessageID}:card:${index + 1}`,
695
+ assistantID,
696
+ role: 'assistant',
697
+ turnID,
698
+ bubbleIndex: 0,
699
+ createdAt: now(),
700
+ ordinal,
701
+ status: 'complete',
702
+ parts: [card],
703
+ });
704
+ ordinal += 1;
705
+ if (card.cardType === 'session' && card.sessionID) {
706
+ upsertContactWatch(db, {
707
+ assistantID,
708
+ sessionID: card.sessionID,
709
+ directory: card.directory,
710
+ status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
711
+ updatedAt: now(),
712
+ });
713
+ }
714
+ });
715
+ bump();
716
+ db.exec('COMMIT');
717
+ } catch (error) {
718
+ db.exec('ROLLBACK');
719
+ throw error;
720
+ }
721
+ };
722
+ const notifyContactTurn = ({ assistantID, name, turnID, status, body }) => {
723
+ if (typeof onContactTurnComplete !== 'function' || closed) return;
724
+ try {
725
+ const result = onContactTurnComplete({
726
+ assistantID,
727
+ name,
728
+ turnID,
729
+ status,
730
+ body: typeof body === 'string' ? body : '',
731
+ });
732
+ if (result && typeof result.then === 'function') {
733
+ result.catch(() => { /* Notification fanout must not fail the turn. */ });
734
+ }
735
+ } catch {
736
+ // Notification fanout must not fail the turn.
737
+ }
738
+ };
739
+ const emitContactTurnEvent = (type, properties) => {
740
+ if (typeof onContactTurnEvent !== 'function' || closed) return;
741
+ const payload = { type, properties: { ...properties, occurredAt: properties?.occurredAt ?? now() } };
742
+ queueMicrotask(() => {
743
+ if (!closed) {
744
+ try { onContactTurnEvent(payload); } catch { /* SSE fan-out must not break the turn. */ }
745
+ }
746
+ });
747
+ };
748
+ // Serialize contact turns per assistant so rapid sends do not interleave persist.
749
+ const contactTurnLanes = new Map();
750
+ const inContactTurnLane = (assistantID, task) => {
751
+ const previous = contactTurnLanes.get(assistantID) ?? Promise.resolve();
752
+ let tail;
753
+ const run = previous.catch(() => {}).then(task);
754
+ tail = run.catch(() => {}).finally(() => {
755
+ if (contactTurnLanes.get(assistantID) === tail) contactTurnLanes.delete(assistantID);
756
+ });
757
+ contactTurnLanes.set(assistantID, tail);
758
+ return run;
759
+ };
760
+ // In-process test hook — never included in the HTTP JSON body.
761
+ const contactTurnSettlements = new Map();
762
+ const contactTurnSettlement = (messageID) => {
763
+ let entry = contactTurnSettlements.get(messageID);
764
+ if (!entry) {
765
+ let resolve;
766
+ const promise = new Promise((settled) => { resolve = settled; });
767
+ entry = { promise, resolve };
768
+ contactTurnSettlements.set(messageID, entry);
769
+ }
770
+ return entry;
771
+ };
772
+ const whenContactTurnSettled = (messageID) => contactTurnSettlement(messageID).promise;
773
+ const resolveContactTurnSettlement = (messageID, result) => {
774
+ contactTurnSettlement(messageID).resolve(result);
775
+ };
776
+ const assignWork = (row, params) => assignSession({
777
+ ...params,
778
+ assistant: output(row),
779
+ defaultProjectPath: row.workspace_path,
780
+ allowedRoots: getAllowedRoots(),
781
+ managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
782
+ listWorktrees,
783
+ createSession: (created) => client().session.create(created),
784
+ promptExisting: (prompted) => client().session.promptAsync(prompted),
785
+ messageID: params?.messageID || `msg_assign_${id()}`,
786
+ });
787
+ const matchRegisteredProject = async (directory) => {
788
+ const projects = await listProjects();
789
+ const resolved = path.resolve(directory);
790
+ let real = resolved;
791
+ try { real = fs.realpathSync(resolved); } catch { /* Compare the resolved path when realpath is unavailable. */ }
792
+ const contained = (candidate, root) => candidate === root || candidate.startsWith(`${root}${path.sep}`);
793
+ for (const project of Array.isArray(projects) ? projects : []) {
794
+ const sanitized = sanitizeRegisteredProject(project);
795
+ if (!sanitized) continue;
796
+ let projectPath = path.resolve(sanitized.path);
797
+ try { projectPath = fs.realpathSync(projectPath); } catch { /* Keep the resolved project path. */ }
798
+ if (projectPath === real || contained(real, projectPath)) {
799
+ return { id: sanitized.id, path: projectPath, ...(sanitized.label ? { label: sanitized.label } : {}) };
800
+ }
801
+ }
802
+ return null;
803
+ };
804
+ const recordAssistantScheduledTask = (assistantID, projectID, taskID) => {
805
+ if (typeof assistantID !== 'string' || !assistantID.trim()) return;
806
+ if (typeof projectID !== 'string' || !projectID.trim()) return;
807
+ if (typeof taskID !== 'string' || !taskID.trim()) return;
808
+ db.prepare(
809
+ 'INSERT OR IGNORE INTO assistant_scheduled_task(assistant_id, project_id, task_id, created_at) VALUES (?,?,?,?)',
810
+ ).run(assistantID, projectID, taskID, now());
811
+ };
812
+ const scheduleWork = async (row, params) => {
813
+ if (typeof upsertScheduledTask !== 'function') {
814
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Scheduling a task is unavailable.');
815
+ }
816
+ const projectDirectory = resolveAssignDirectory({
817
+ projectPath: params?.projectPath,
818
+ directory: params?.directory,
819
+ branch: null,
820
+ allowedRoots: getAllowedRoots(),
821
+ managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
822
+ defaultProjectPath: row.workspace_path,
823
+ });
824
+ const project = await matchRegisteredProject(projectDirectory);
825
+ if (!project) {
826
+ throw new AssignError(
827
+ ASSIGN_CODES.PROJECT_REQUIRED,
828
+ 'Choose a registered project path. Several projects are configured; schedule_task cannot guess.',
829
+ );
830
+ }
831
+ const kind = typeof params?.kind === 'string' && params.kind.trim() ? params.kind.trim() : 'daily';
832
+ const weekdays = Array.isArray(params?.weekdays)
833
+ ? params.weekdays
834
+ : typeof params?.weekdays === 'string'
835
+ ? params.weekdays.split(',').map((item) => Number(item.trim())).filter((item) => Number.isInteger(item))
836
+ : undefined;
837
+ const upserted = await upsertScheduledTask(project.id, {
838
+ name: params.name,
839
+ enabled: true,
840
+ schedule: {
841
+ kind,
842
+ ...(params.time ? { time: params.time } : {}),
843
+ ...(params.timezone ? { timezone: params.timezone } : {}),
844
+ ...(params.date ? { date: params.date } : {}),
845
+ ...(weekdays && weekdays.length > 0 ? { weekdays } : {}),
846
+ ...(params.cron ? { cron: params.cron } : {}),
847
+ },
848
+ execution: {
849
+ prompt: params.prompt,
850
+ providerID: params.providerID,
851
+ modelID: params.modelID,
852
+ },
853
+ });
854
+ const taskID = upserted?.task?.id;
855
+ if (typeof taskID === 'string' && taskID.trim()) {
856
+ recordAssistantScheduledTask(row.assistant_id, project.id, taskID);
857
+ }
858
+ if (typeof syncScheduledTaskProject === 'function') {
859
+ try { await syncScheduledTaskProject(project.id); } catch { /* Card persistence does not depend on scheduler sync. */ }
860
+ }
861
+ return {
862
+ task: upserted?.task,
863
+ taskID,
864
+ projectID: project.id,
865
+ name: upserted?.task?.name || params.name,
866
+ kind: upserted?.task?.schedule?.kind || kind,
867
+ time: upserted?.task?.schedule?.time || upserted?.task?.schedule?.times?.[0] || params.time || null,
868
+ timezone: upserted?.task?.schedule?.timezone || params.timezone || null,
869
+ prompt: upserted?.task?.execution?.prompt || params.prompt,
870
+ };
871
+ };
872
+ const listSessionsFromIndex = (params = {}) => {
873
+ if (!sessionIndexService || typeof sessionIndexService.snapshot !== 'function') {
874
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index is unavailable.');
875
+ }
876
+ let snapshot;
877
+ try {
878
+ snapshot = sessionIndexService.snapshot();
879
+ } catch (error) {
880
+ throw new AssignError(
881
+ ASSIGN_CODES.UPSTREAM,
882
+ typeof error?.message === 'string' && error.message.trim()
883
+ ? error.message.trim()
884
+ : 'Session index snapshot failed.',
885
+ );
886
+ }
887
+ if (!snapshot || !Array.isArray(snapshot.directories)) {
888
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index snapshot failed.');
889
+ }
890
+ const limit = boundSessionListLimit(params.limit);
891
+ const query = typeof params.query === 'string' ? params.query.trim() : '';
892
+ const projectPath = typeof params.projectPath === 'string' ? params.projectPath.trim() : '';
893
+ const projectID = typeof params.projectID === 'string' ? params.projectID.trim() : '';
894
+ const scopeRoots = [];
895
+ if (projectPath) scopeRoots.push(projectPath);
896
+ if (Array.isArray(params.projectRoots)) {
897
+ for (const root of params.projectRoots) {
898
+ if (typeof root === 'string' && root.trim()) scopeRoots.push(root.trim());
899
+ }
900
+ }
901
+ const sessions = [];
902
+ for (const directory of snapshot.directories) {
903
+ const dir = typeof directory?.directory === 'string' ? directory.directory : '';
904
+ if (!dir) continue;
905
+ if (scopeRoots.length > 0 && !scopeRoots.some((root) => directoryContained(dir, root))) continue;
906
+ for (const session of Array.isArray(directory.sessions) ? directory.sessions : []) {
907
+ if (!session || typeof session.id !== 'string' || !session.id) continue;
908
+ const title = typeof session.title === 'string' ? session.title : '';
909
+ const updatedAt = typeof session.time?.updated === 'number'
910
+ ? session.time.updated
911
+ : (typeof session.updatedAt === 'number' ? session.updatedAt : 0);
912
+ const row = {
913
+ sessionID: session.id,
914
+ title,
915
+ directory: dir,
916
+ updatedAt,
917
+ };
918
+ if (query && !matchesProjectQuery({ ...row, id: session.id }, query)) continue;
919
+ sessions.push(row);
920
+ }
921
+ }
922
+ sessions.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
923
+ const truncated = sessions.length > limit;
924
+ return {
925
+ sessions: sessions.slice(0, limit),
926
+ truncated,
927
+ source: 'session-index',
928
+ ...(projectID ? { projectID } : {}),
929
+ };
930
+ };
931
+ const listSessionsWork = async (params = {}) => {
932
+ const projectPath = typeof params.projectPath === 'string' ? params.projectPath.trim() : '';
933
+ const projectID = typeof params.projectID === 'string' ? params.projectID.trim() : '';
934
+ let projectRoots = [];
935
+ if (projectID || projectPath) {
936
+ let projects;
937
+ try {
938
+ projects = await listProjects();
939
+ } catch (error) {
940
+ throw new AssignError(
941
+ ASSIGN_CODES.UPSTREAM,
942
+ typeof error?.message === 'string' && error.message.trim()
943
+ ? error.message.trim()
944
+ : 'Registered project catalog failed to load.',
945
+ );
946
+ }
947
+ if (!Array.isArray(projects)) {
948
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
949
+ }
950
+ const normalized = normalizeRegisteredProjects(projects);
951
+ if (projectID) {
952
+ const match = normalized.find((project) => project.id === projectID);
953
+ if (!match) {
954
+ throw new AssignError(ASSIGN_CODES.PROJECT_REQUIRED, `No registered project with id ${projectID}.`);
955
+ }
956
+ projectRoots = [match.path];
957
+ } else if (projectPath) {
958
+ const match = await matchRegisteredProject(projectPath);
959
+ if (match) projectRoots = [match.path];
960
+ else projectRoots = [projectPath];
961
+ }
962
+ }
963
+ return listSessionsFromIndex({
964
+ ...params,
965
+ projectPath: projectRoots[0] || projectPath || undefined,
966
+ projectRoots,
967
+ projectID: projectID || undefined,
968
+ });
969
+ };
970
+ const listAssistantScheduledTasks = async (assistantID) => {
971
+ editable(assistantID);
972
+ const mappings = db.prepare(
973
+ 'SELECT assistant_id AS assistantID, project_id AS projectID, task_id AS taskID, created_at AS createdAt FROM assistant_scheduled_task WHERE assistant_id=? ORDER BY created_at DESC, task_id ASC',
974
+ ).all(assistantID);
975
+ if (mappings.length === 0) return { tasks: [] };
976
+ let projects = [];
977
+ try {
978
+ const listed = await listProjects();
979
+ projects = normalizeRegisteredProjects(Array.isArray(listed) ? listed : []);
980
+ } catch {
981
+ // Failed project lookups must not wipe the mapping.
982
+ projects = [];
983
+ }
984
+ const projectByID = new Map(projects.map((project) => [project.id, project]));
985
+ const liveByKey = new Map();
986
+ if (typeof listScheduledTasks === 'function') {
987
+ const projectIDs = [...new Set(mappings.map((row) => row.projectID))];
988
+ await Promise.all(projectIDs.map(async (projectID) => {
989
+ try {
990
+ const tasks = await listScheduledTasks(projectID);
991
+ if (!Array.isArray(tasks)) return;
992
+ for (const task of tasks) {
993
+ if (task && typeof task.id === 'string') liveByKey.set(`${projectID}:${task.id}`, task);
994
+ }
995
+ } catch {
996
+ // Keep the mapping row with task:null when live lookup fails.
997
+ }
998
+ }));
999
+ }
1000
+ return {
1001
+ tasks: mappings.map((row) => {
1002
+ const project = projectByID.get(row.projectID) || null;
1003
+ const live = liveByKey.get(`${row.projectID}:${row.taskID}`) || null;
1004
+ return {
1005
+ assistantID: row.assistantID,
1006
+ projectID: row.projectID,
1007
+ taskID: row.taskID,
1008
+ createdAt: row.createdAt,
1009
+ projectPath: project?.path ?? null,
1010
+ projectLabel: project?.label ?? null,
1011
+ task: live,
1012
+ };
1013
+ }),
1014
+ };
1015
+ };
1016
+ /**
1017
+ * Contact composer send: validate → admit user (202) → async turn.
1018
+ * Does not await the LLM. Live bubble tokens use openchamber:contact-bubble-delta
1019
+ * on /api/openchamber/events; turn lifecycle uses contact-turn-start/end.
1020
+ */
1021
+ const send = async (assistantID, input) => {
1022
+ if (!plainObject(input)) fail('validation_error');
1023
+ if (input.parts !== undefined) validateParts(input.parts);
1024
+ const messageID = string(input.messageID, 256, true);
1025
+ const userText = extractUserText(input);
1026
+ if (!userText) fail('validation_error');
1027
+ const userParts = userContactParts(input, userText);
1028
+ const row = active(assistantID);
1029
+ // Snapshot history before admitting the user so the harness does not see a
1030
+ // duplicate of the current userText (prompt still receives userText).
1031
+ const history = contactHistoryForLlm(db, assistantID);
1032
+ if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1033
+ let registeredProjects = [];
1034
+ try {
1035
+ const listed = await listProjects();
1036
+ if (!Array.isArray(listed)) {
1037
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
1038
+ }
1039
+ registeredProjects = filterRegisteredProjects(listed);
1040
+ } catch (error) {
1041
+ if (error instanceof AssignError || error instanceof AssistantError) throw error;
1042
+ // Still allow the turn — tools can surface a later catalog failure.
1043
+ registeredProjects = [];
1044
+ }
1045
+ const turnID = messageID;
1046
+ const assistantSnapshot = output(row);
1047
+ admitContactUser(row.assistant_id, {
1048
+ userMessageID: messageID,
1049
+ userText,
1050
+ userParts,
1051
+ turnID,
1052
+ });
1053
+ emitContactTurnEvent('openchamber:contact-turn-start', {
1054
+ assistantID: row.assistant_id,
1055
+ turnID,
1056
+ messageID,
1057
+ });
1058
+ contactTurnSettlement(messageID);
1059
+ const kickTurn = () => {
1060
+ void inContactTurnLane(row.assistant_id, async () => {
1061
+ const assignedCards = [];
1062
+ let contactResetThisTurn = false;
1063
+ const tools = createContactTools({
1064
+ assignWork: (params) => assignWork(row, params),
1065
+ createAssistant: (toolInput) => createAssistant(toolInput),
1066
+ scheduleTask: (params) => scheduleWork(row, params),
1067
+ deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
1068
+ resetContact: () => {
1069
+ const result = resetContact(row.assistant_id);
1070
+ contactResetThisTurn = true;
1071
+ return result;
1072
+ },
1073
+ listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1074
+ listProjects: async () => {
1075
+ const listed = await listProjects();
1076
+ if (!Array.isArray(listed)) {
1077
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
1078
+ }
1079
+ return listed;
1080
+ },
1081
+ listSessions: (params) => listSessionsWork(params),
1082
+ currentAssistant: assistantSnapshot,
1083
+ onCard: (card) => assignedCards.push(card),
1084
+ });
1085
+ try {
1086
+ const generated = await runContactTurn({
1087
+ assistant: assistantSnapshot,
1088
+ history,
1089
+ userText,
1090
+ userParts,
1091
+ createChatCompletion,
1092
+ tools,
1093
+ projects: registeredProjects,
1094
+ globalEventHub,
1095
+ onBubbleDelta: (bubbleIndex, delta, done) => {
1096
+ emitContactTurnEvent('openchamber:contact-bubble-delta', {
1097
+ assistantID: row.assistant_id,
1098
+ turnID,
1099
+ bubbleIndex,
1100
+ delta: typeof delta === 'string' ? delta : '',
1101
+ done: Boolean(done),
1102
+ });
1103
+ },
1104
+ });
1105
+ if (closed) {
1106
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1107
+ return;
1108
+ }
1109
+ const resetThisTurn = contactResetThisTurn || generated?.reset === true;
1110
+ const bubbles = resetThisTurn
1111
+ ? confirmBubbleAfterContactReset(generated?.bubbles)
1112
+ : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
1113
+ const cards = resetThisTurn
1114
+ ? []
1115
+ : [
1116
+ ...assignedCards,
1117
+ ...(Array.isArray(generated?.cards) ? generated.cards : []),
1118
+ ].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
1119
+ if (bubbles.length === 0 && cards.length === 0) {
1120
+ const detail = 'Assistant returned no text';
1121
+ emitContactTurnEvent('openchamber:contact-turn-end', {
1122
+ assistantID: row.assistant_id,
1123
+ turnID,
1124
+ status: 'error',
1125
+ error: detail,
1126
+ });
1127
+ notifyContactTurn({
1128
+ assistantID: row.assistant_id,
1129
+ name: assistantSnapshot.name,
1130
+ turnID,
1131
+ status: 'error',
1132
+ body: detail,
1133
+ });
1134
+ resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
1135
+ return;
1136
+ }
1137
+ // new_conversation wipes the transcript (including the admitted user row).
1138
+ // Re-admit the reset user message, then persist only the confirm bubble.
1139
+ if (resetThisTurn) {
1140
+ const existingUser = db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(messageID);
1141
+ if (!existingUser) {
1142
+ admitContactUser(row.assistant_id, {
1143
+ userMessageID: messageID,
1144
+ userText,
1145
+ userParts,
1146
+ turnID,
1147
+ });
1148
+ }
1149
+ }
1150
+ persistContactAssistantReply(row.assistant_id, {
1151
+ userMessageID: messageID,
1152
+ bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
1153
+ cards,
1154
+ turnID,
1155
+ });
1156
+ emitContactTurnEvent('openchamber:contact-turn-end', {
1157
+ assistantID: row.assistant_id,
1158
+ turnID,
1159
+ status: 'complete',
1160
+ });
1161
+ const spoken = bubbles.filter((text) => typeof text === 'string' && text.trim() && !text.startsWith('oc.settle.'));
1162
+ notifyContactTurn({
1163
+ assistantID: row.assistant_id,
1164
+ name: assistantSnapshot.name,
1165
+ turnID,
1166
+ status: 'complete',
1167
+ body: spoken.join('\n') || cards[0]?.title || '',
1168
+ });
1169
+ resolveContactTurnSettlement(messageID, { status: 'complete' });
1170
+ } catch (error) {
1171
+ const detail = typeof error?.message === 'string' && error.message.trim()
1172
+ ? error.message.trim()
1173
+ : (error?.code || 'upstream_error');
1174
+ const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
1175
+ emitContactTurnEvent('openchamber:contact-turn-end', {
1176
+ assistantID: row.assistant_id,
1177
+ turnID,
1178
+ status: 'error',
1179
+ error: detail,
1180
+ ...(statusCode !== detail ? { code: statusCode } : {}),
1181
+ });
1182
+ notifyContactTurn({
1183
+ assistantID: row.assistant_id,
1184
+ name: assistantSnapshot.name,
1185
+ turnID,
1186
+ status: 'error',
1187
+ body: detail,
1188
+ });
1189
+ resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
1190
+ }
1191
+ });
1192
+ };
1193
+ // Defer past send() resolution so callers observe admission before assistant rows.
1194
+ setImmediateFn(kickTurn);
1195
+ return { binding: binding(row), messageID, admitted: true };
1196
+ };
1197
+ const contactMessages = (assistantID, query = {}) => {
1198
+ editable(assistantID);
1199
+ const limit = query.limit == null ? 50 : Number(query.limit);
1200
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error');
1201
+ return listContactMessages(db, assistantID, { before: query.before, limit });
1202
+ };
1203
+ /**
1204
+ * Clear this assistant's OpenChamber contact transcript (messages, parts,
1205
+ * watches). Does not call OpenCode session/new — that is worker binding.
1206
+ */
1207
+ const resetContact = (assistantID) => {
1208
+ const row = editable(assistantID);
1209
+ db.exec('BEGIN IMMEDIATE');
1210
+ try {
1211
+ deleteContactMessages(db, row.assistant_id);
1212
+ bump();
1213
+ db.exec('COMMIT');
1214
+ } catch (error) {
1215
+ db.exec('ROLLBACK');
1216
+ throw error;
1217
+ }
1218
+ return { assistantID: row.assistant_id, reset: true };
1219
+ };
1220
+ const appendContactCard = (assistantID, input) => {
1221
+ const row = active(assistantID);
1222
+ const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
1223
+ if (!card) fail('validation_error');
1224
+ const messageID = string(input.messageID, 256) || `card_${id()}`;
1225
+ db.exec('BEGIN IMMEDIATE');
1226
+ try {
1227
+ insertContactMessage(db, {
1228
+ messageID,
1229
+ assistantID: row.assistant_id,
1230
+ role: input.role === 'user' ? 'user' : 'assistant',
1231
+ turnID: string(input.turnID, 256) || messageID,
1232
+ bubbleIndex: 0,
1233
+ createdAt: now(),
1234
+ ordinal: nextContactOrdinal(db, row.assistant_id),
1235
+ status: 'complete',
1236
+ parts: [card],
1237
+ });
1238
+ if (card.cardType === 'session' && card.sessionID) {
1239
+ upsertContactWatch(db, {
1240
+ assistantID: row.assistant_id,
1241
+ sessionID: card.sessionID,
1242
+ directory: card.directory,
1243
+ status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1244
+ updatedAt: now(),
1245
+ });
1246
+ }
1247
+ bump();
1248
+ db.exec('COMMIT');
1249
+ } catch (error) {
1250
+ db.exec('ROLLBACK');
1251
+ throw error;
1252
+ }
1253
+ return { messageID, admitted: true, card };
1254
+ };
1255
+ /**
1256
+ * Read-only inter-assistant DM. Inserts into the recipient's OpenChamber
1257
+ * contact transcript only. Must never call OpenCode promptAsync, mutate
1258
+ * sessions/files/worktrees, or run tools on the recipient's behalf.
1259
+ *
1260
+ * Assign opens a worker session on the sender's contact turn — never here.
1261
+ * TODO(watch/summon): later inbound coordination may attach cards on this same peer row.
1262
+ */
1263
+ const deliverPeerMessage = (fromAssistantID, input) => {
1264
+ if (!plainObject(input)) fail('validation_error');
1265
+ const sender = active(fromAssistantID);
1266
+ const toAssistantID = string(input.toAssistantID, 256, true);
1267
+ if (toAssistantID === sender.assistant_id) fail('validation_error');
1268
+ const recipient = active(toAssistantID);
1269
+ const parts = [];
1270
+ const text = typeof input.text === 'string' ? input.text.trim() : '';
1271
+ if (text) parts.push({ type: 'text', text });
1272
+ if (input.parts !== undefined) {
1273
+ if (!Array.isArray(input.parts)) fail('validation_error');
1274
+ for (const part of input.parts) {
1275
+ const parsed = parseContactPart(part);
1276
+ if (!parsed) fail('validation_error');
1277
+ parts.push(parsed);
1278
+ }
1279
+ }
1280
+ if (parts.length === 0) fail('validation_error');
1281
+ const messageID = string(input.messageID, 256) || `peer_${id()}`;
1282
+ db.exec('BEGIN IMMEDIATE');
1283
+ try {
1284
+ insertContactMessage(db, {
1285
+ messageID,
1286
+ assistantID: recipient.assistant_id,
1287
+ role: 'peer',
1288
+ turnID: string(input.turnID, 256) || messageID,
1289
+ bubbleIndex: 0,
1290
+ createdAt: now(),
1291
+ ordinal: nextContactOrdinal(db, recipient.assistant_id),
1292
+ status: 'complete',
1293
+ parts,
1294
+ fromAssistantID: sender.assistant_id,
1295
+ fromAssistantName: sender.name,
1296
+ });
1297
+ bump();
1298
+ db.exec('COMMIT');
1299
+ } catch (error) {
1300
+ db.exec('ROLLBACK');
1301
+ throw error;
1302
+ }
1303
+ return {
1304
+ messageID,
1305
+ admitted: true,
1306
+ role: 'peer',
1307
+ fromAssistantID: sender.assistant_id,
1308
+ fromAssistantName: sender.name,
1309
+ toAssistantID: recipient.assistant_id,
1310
+ };
1311
+ };
411
1312
  const captureQueueDeliveryTarget = ({ assistantID, scope }) => {
412
1313
  const row = active(assistantID); const current = binding(row);
413
1314
  const expectedSessionID = row.mode === 'stateless' ? `assistant:${assistantID}` : current.sessionID;
@@ -433,14 +1334,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
433
1334
  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; } };
434
1335
  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); };
435
1336
  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'); } };
436
- const reconcile = 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. */ } } };
1337
+ 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. */ } } };
1338
+ const reconcile = async () => {
1339
+ if (closed) return;
1340
+ try {
1341
+ await reconcileShareOperations();
1342
+ if (closed) return;
1343
+ await reconcileInFlightWatches();
1344
+ } catch {
1345
+ // Reconcile remains retryable on the next timer.
1346
+ }
1347
+ };
437
1348
  const share = async (assistantID, input) => { if (!plainObject(input) || !plainObject(input.payload)) fail('validation_error'); validateParts(input.payload.parts); const operationID = string(input.operationID, 128, true); const messageID = string(input.payload.messageID, 256, true); const payloadHash = hash(input.payload); active(assistantID); const at = now(); let operation; let reservationOwner = false; db.exec('BEGIN IMMEDIATE'); try { const inserted = db.prepare('INSERT OR IGNORE INTO assistant_share_operation(operation_id,assistant_id,payload_hash,phase,session_id,message_id,state,response,error_code,attempt,lease_expires_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)').run(operationID, assistantID, payloadHash, 'reserving', null, messageID, 'running', json(input.payload), null, 0, null, at, at); operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (operation.assistant_id !== assistantID || operation.payload_hash !== payloadHash) fail('idempotency_conflict'); reservationOwner = inserted.changes === 1; db.exec('COMMIT'); } catch (error) { db.exec('ROLLBACK'); throw error; }
438
1349
  let resolveReservation; if (reservationOwner) shareReservations.set(operationID, new Promise((resolve) => { resolveReservation = resolve; })); else await shareReservations.get(operationID);
439
1350
  if (reservationOwner) {
440
1351
  try { const row = active(assistantID); const target = row.mode === 'stateless' ? await createNew(assistantID) : await ensure(assistantID); const attachedAt = now(); const attached = db.prepare("UPDATE assistant_share_operation SET phase='admitted',session_id=?,message_id=?,lease_expires_at=?,updated_at=? WHERE operation_id=? AND state='running' AND phase='reserving'").run(target.sessionID, messageID, attachedAt, attachedAt, operationID); if (!attached.changes) fail('upstream_error'); } catch (error) { db.prepare("DELETE FROM assistant_share_operation WHERE operation_id=? AND state='running' AND phase='reserving'").run(operationID); throw error; } finally { shareReservations.delete(operationID); resolveReservation(); }
441
1352
  }
442
1353
  operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (operation?.phase !== 'reserving') { const claimed = claim(operationID, operation?.state === 'failed'); if (claimed) await submitClaim(claimed); } return shareOperation(operationID); };
443
- const timer = setIntervalFn(() => { if (!closed) { db.prepare('DELETE FROM assistant_share_operation WHERE updated_at<?').run(now() - SHARE_RETENTION_MS); void reconcile(); } }, reconcileIntervalMs);
1354
+ const timer = setIntervalFn(() => { if (!closed) { db.prepare('DELETE FROM assistant_share_operation WHERE updated_at<?').run(now() - SHARE_RETENTION_MS); return reconcile(); } }, reconcileIntervalMs);
444
1355
  void reconcile();
445
1356
  return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot: () => ({ revision: revision(), enabled: enabled(), assistants: db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output) }), 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) => {
446
1357
  db.exec('BEGIN IMMEDIATE');
@@ -451,6 +1362,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
451
1362
  db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=?').run(assistantID);
452
1363
  db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=?').run(assistantID);
453
1364
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
1365
+ db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
1366
+ deleteContactMessages(db, assistantID);
454
1367
  bump();
455
1368
  db.exec('COMMIT');
456
1369
  return { assistantID, tombstoneAt: now() };
@@ -458,5 +1371,5 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
458
1371
  db.exec('ROLLBACK');
459
1372
  throw error;
460
1373
  }
461
- }, ensure, createNew, compact, send, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, processEvent, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
1374
+ }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
462
1375
  };