@openchambery/web 1.19.12 → 1.19.13-beta.2

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 (168) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-mbvX7YlW.js} +1 -1
  2. package/dist/assets/AssistantView-D5c557Oe.js +2 -0
  3. package/dist/assets/DiagramView-CjNUrHsU.js +1 -0
  4. package/dist/assets/FadeInOnReveal-moBbL8Ll.js +1 -0
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CkzzhaLr.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-BHfbAwVO.js +2 -0
  7. package/dist/assets/MarkdownRendererImpl-CkBoUibc.js +7 -0
  8. package/dist/assets/{MarkstreamRendererImpl-BFdJzxia.js → MarkstreamRendererImpl-BrXUOI9g.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-aY9-HwBN.js +1 -0
  10. package/dist/assets/MobileShareBridge-CRt_8DmM.js +4 -0
  11. package/dist/assets/MobileSurface-CIhtF-CU.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C3p3lj3U.js → MultiRunWindow-DDq6C8t7.js} +1 -1
  13. package/dist/assets/OnboardingScreen-j0iCyH7f.js +1 -0
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-CDC5B3RH.js} +1 -1
  15. package/dist/assets/SettingsView-DFWWMvAj.js +25 -0
  16. package/dist/assets/{SettingsWindow-8IDuPZA5.js → SettingsWindow-CnYfBplB.js} +1 -1
  17. package/dist/assets/TerminalView-D5CQFVVm.js +8 -0
  18. package/dist/assets/ToolOutputDialog-VMI7TQFT.js +10 -0
  19. package/dist/assets/{appThemeRegistry-B87OLIgc.js → appThemeRegistry-UIrP0-5h.js} +1 -1
  20. package/dist/assets/assistant-attachment-cache-C0aRnFkj.js +1 -0
  21. package/dist/assets/{button-C0cth9tl.js → button-xx1UOBA5.js} +1 -1
  22. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-CUasgPiF.js} +1 -1
  23. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-CACHBRYZ.js} +1 -1
  24. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-CYbm4wBZ.js} +1 -1
  25. package/dist/assets/desktopHosts-DILvrAok.js +1 -0
  26. package/dist/assets/desktopRelayRestore-t8asCr3m.js +1 -0
  27. package/dist/assets/{diagram-BryzMuta.js → diagram-u0BC9uuY.js} +1 -1
  28. package/dist/assets/{dialog-CgbV5ljg.js → dialog-BwQ3rY_x.js} +1 -1
  29. package/dist/assets/dualLimitLru-D8Sphgua.js +1 -0
  30. package/dist/assets/{es-BHqnSjoZ.js → es-CRjOwvYM.js} +1 -1
  31. package/dist/assets/fileQueries-Bgly-E8f.js +1 -0
  32. package/dist/assets/{fr-BfrlJY9i.js → fr-D9taEN9L.js} +1 -1
  33. package/dist/assets/{gitApi-tTtHQVA3.js → gitApi-Bu4W3bgD.js} +1 -1
  34. package/dist/assets/imageSource-BlAJJUEI.js +1 -0
  35. package/dist/assets/{input-DFLpqjGR.js → input-CI730njs.js} +1 -1
  36. package/dist/assets/insertionBoundaries-B1AqxsBD.js +4 -0
  37. package/dist/assets/{ja-DP-7qt-x.js → ja-CYQ5aoWp.js} +1 -1
  38. package/dist/assets/{ko-E2B5vGQP.js → ko-BwASV0Q0.js} +1 -1
  39. package/dist/assets/main-CqcB6tKc.js +169 -0
  40. package/dist/assets/main-DG-k0j8A.js +2 -0
  41. package/dist/assets/markdown-worker-CptoICmq.js +2 -0
  42. package/dist/assets/markdownHeightCache-DEjGKLv8.js +3 -0
  43. package/dist/assets/markdownTheme-DOfGg-A7.js +1 -0
  44. package/dist/assets/miniChat-BQtYQrsh.js +2 -0
  45. package/dist/assets/mobile-8iyqtXkr.js +2 -0
  46. package/dist/assets/{multirun-B0V3hdnD.js → multirun-CG_FCb_Z.js} +1 -1
  47. package/dist/assets/persistence-YbQKzHUQ.js +3 -0
  48. package/dist/assets/{pl-r8X5gRM5.js → pl-DUI1vPil.js} +1 -1
  49. package/dist/assets/platform-DzsGqlGW.js +2 -0
  50. package/dist/assets/projectMeta-DMpMDWE1.js +3 -0
  51. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-CZQFJllP.js} +1 -1
  52. package/dist/assets/relay-image-stream-DM7atqJs.js +2 -0
  53. package/dist/assets/renderElectronMiniChatApp-DBzRdJl8.js +2 -0
  54. package/dist/assets/renderMobileApp-CIseqCyn.js +28 -0
  55. package/dist/assets/runtime-auth-D66yq6ie.js +1 -0
  56. package/dist/assets/runtime-fetch-zIalUnbb.js +1 -0
  57. package/dist/assets/runtime-switch-LvWDU-SL.js +1 -0
  58. package/dist/assets/runtimeConfig-D8nJYB-e.js +2 -0
  59. package/dist/assets/runtimeEndpointReset-CSub_ecV.js +147 -0
  60. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DQDZ5HW-.js} +1 -1
  61. package/dist/assets/{sessionLookup-B6P_aCun.js → sessionLookup-BiBD-4wC.js} +1 -1
  62. package/dist/assets/sortable-tabs-strip-Dom6Sthu.js +1 -0
  63. package/dist/assets/src-AA-5nXsF.css +2 -0
  64. package/dist/assets/{store-BDFdnk6l.js → store-CSnH_QXM.js} +2 -2
  65. package/dist/assets/terminalApi-noa4Cdbh.js +1 -0
  66. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BfNtUdJE.js} +1 -1
  67. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-DPxumadF.js} +1 -1
  68. package/dist/assets/{uk-Di0vl9V-.js → uk-C1FYZlmA.js} +1 -1
  69. package/dist/assets/{url-Pk6tQ1EZ.js → url-CVhsI9kR.js} +1 -1
  70. package/dist/assets/{useAppFontEffects-Dmes1chr.js → useAppFontEffects-qHt5uaFN.js} +32 -32
  71. package/dist/assets/useAssistantUIStore-CLPCNoj_.js +1 -0
  72. package/dist/assets/useDesktopWindowControlsLayout-BipyIXiK.js +1 -0
  73. package/dist/assets/{useEffectiveDirectory-CU2ChP0J.js → useEffectiveDirectory-DSNCCbCt.js} +1 -1
  74. package/dist/assets/useFontPreferences-CB-cGo3I.js +1 -0
  75. package/dist/assets/useMobileNavigationStore-C1NJGAik.js +413 -0
  76. package/dist/assets/useSessionAutoCleanup-Dcn6Unvd.js +2 -0
  77. package/dist/assets/useUIStore-CxOoCv7B.js +1 -0
  78. package/dist/assets/useWorkerHighlightedLines-CzpT4M7V.js +7 -0
  79. package/dist/assets/{utils-BiK3tBGM.js → utils-BHTVlVov.js} +1 -1
  80. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-C96qP1ip.js} +1 -1
  81. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-BsRk1Rbb.js} +1 -1
  82. package/dist/index.html +12 -9
  83. package/dist/mini-chat.html +12 -9
  84. package/dist/mobile.html +12 -9
  85. package/package.json +1 -1
  86. package/server/index.js +19 -0
  87. package/server/lib/assistant-delivery-parts.js +19 -1
  88. package/server/lib/assistants/DOCUMENTATION.md +56 -11
  89. package/server/lib/assistants/assign.js +455 -30
  90. package/server/lib/assistants/assign.test.js +351 -0
  91. package/server/lib/assistants/cards.js +16 -2
  92. package/server/lib/assistants/cards.test.js +16 -1
  93. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  94. package/server/lib/assistants/contact-attachments.http.test.js +436 -0
  95. package/server/lib/assistants/contact-attachments.js +814 -0
  96. package/server/lib/assistants/contact-attachments.p1.test.js +307 -0
  97. package/server/lib/assistants/contact-attachments.test.js +249 -0
  98. package/server/lib/assistants/contact-store.js +552 -19
  99. package/server/lib/assistants/contact-store.test.js +415 -1
  100. package/server/lib/assistants/contact-tools.js +114 -14
  101. package/server/lib/assistants/contact-tools.test.js +135 -6
  102. package/server/lib/assistants/contracts.js +42 -2
  103. package/server/lib/assistants/harness.js +4 -1
  104. package/server/lib/assistants/harness.test.js +124 -0
  105. package/server/lib/assistants/routes.js +46 -1
  106. package/server/lib/assistants/service.js +569 -84
  107. package/server/lib/assistants/service.test.js +936 -37
  108. package/server/lib/fs/prompt-attachment-store.js +663 -0
  109. package/server/lib/fs/prompt-attachment-store.test.js +263 -0
  110. package/server/lib/fs/routes.js +28 -72
  111. package/server/lib/fs/routes.test.js +15 -30
  112. package/server/lib/llm/catalog.js +91 -16
  113. package/server/lib/llm/catalog.test.js +152 -11
  114. package/server/lib/notifications/APNS.md +20 -4
  115. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  116. package/server/lib/notifications/apns-runtime.js +136 -18
  117. package/server/lib/notifications/apns-runtime.test.js +128 -0
  118. package/server/lib/notifications/apns-titles.js +21 -1
  119. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  120. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  121. package/server/lib/notifications/runtime.js +93 -2
  122. package/server/lib/notifications/runtime.test.js +167 -4
  123. package/server/lib/opencode/core-routes.js +2 -1
  124. package/server/lib/opencode/settings-helpers.js +12 -0
  125. package/server/lib/opencode/settings-runtime.js +16 -0
  126. package/server/lib/opencode/settings-runtime.test.js +21 -0
  127. package/server/lib/scheduled-tasks/runtime.js +21 -0
  128. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  129. package/dist/assets/AssistantView-DxIghtun.js +0 -2
  130. package/dist/assets/DiagramView-BeUgp_Y0.js +0 -1
  131. package/dist/assets/FadeInOnReveal-CTlNE4Hg.js +0 -1
  132. package/dist/assets/MarkdownRenderer-C4MGa_kQ.js +0 -2
  133. package/dist/assets/MarkdownRendererImpl-DB4Q3kGa.js +0 -7
  134. package/dist/assets/MobileDetailNavigation-B_RwWOwv.js +0 -1
  135. package/dist/assets/MobileShareBridge-UiOLx0-2.js +0 -4
  136. package/dist/assets/MobileSurface-CNChj_dX.js +0 -2
  137. package/dist/assets/OnboardingScreen-DS2nugLX.js +0 -1
  138. package/dist/assets/SettingsView-BXfKsz0J.js +0 -25
  139. package/dist/assets/TerminalView-BiKCueth.js +0 -8
  140. package/dist/assets/ToolOutputDialog-BC2F_Fdl.js +0 -10
  141. package/dist/assets/desktopHosts-Dc2PS66M.js +0 -1
  142. package/dist/assets/desktopRelayRestore-T9y0orqa.js +0 -1
  143. package/dist/assets/fileQueries-BY-9OWzp.js +0 -1
  144. package/dist/assets/insertionBoundaries-CCQpZ5zn.js +0 -4
  145. package/dist/assets/main-C_PUoT3E.js +0 -169
  146. package/dist/assets/main-Dkg31fRY.js +0 -2
  147. package/dist/assets/markdown-worker-CTsrtpPh.js +0 -2
  148. package/dist/assets/markdownHeightCache-CYDlisxr.js +0 -3
  149. package/dist/assets/markdownTheme-DyJ4Gfcj.js +0 -2
  150. package/dist/assets/miniChat-C9QMj8T8.js +0 -2
  151. package/dist/assets/mobile-Z_X7CbzB.js +0 -2
  152. package/dist/assets/persistence-DdUac14I.js +0 -3
  153. package/dist/assets/platform-B4KKLnZ5.js +0 -2
  154. package/dist/assets/projectMeta-DRW9MQ4z.js +0 -3
  155. package/dist/assets/renderElectronMiniChatApp-Dn1Su1dd.js +0 -2
  156. package/dist/assets/renderMobileApp-BG1JJt1Q.js +0 -28
  157. package/dist/assets/runtimeConfig-C6matIEB.js +0 -2
  158. package/dist/assets/runtimeEndpointReset-kgwmUJDb.js +0 -147
  159. package/dist/assets/sortable-tabs-strip-C8_9kHqu.js +0 -1
  160. package/dist/assets/src-B-P5qIBc.css +0 -2
  161. package/dist/assets/terminalApi-947l7Ra3.js +0 -1
  162. package/dist/assets/useAssistantUIStore-me6s63b2.js +0 -1
  163. package/dist/assets/useDesktopWindowControlsLayout-DuZ6URPR.js +0 -1
  164. package/dist/assets/useFontPreferences-BCOBlaAd.js +0 -1
  165. package/dist/assets/useMobileNavigationStore-C8_J-qTp.js +0 -413
  166. package/dist/assets/useSessionAutoCleanup-FOQeu1_T.js +0 -1
  167. package/dist/assets/useUIStore-CuaA8-Hp.js +0 -1
  168. package/dist/assets/useWorkerHighlightedLines-GBqKnki1.js +0 -7
@@ -8,19 +8,43 @@ import { reduceBackfillState } from './history-state.js';
8
8
  import { getWorktrees as defaultListWorktrees } from '../git/service.js';
9
9
  import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
10
10
  import {
11
+ CONTACT_PAGE_DEFAULT_LIMIT,
12
+ CONTACT_PAGE_MAX_LIMIT,
11
13
  CONTACT_SETTLE_TEXT,
14
+ bumpContactGeneration,
12
15
  clearContactMemory as clearContactMemoryStore,
13
16
  contactHistoryForLlm,
17
+ createActiveContactTurn,
14
18
  deleteContactMessages,
15
19
  ensureContactSchema,
20
+ contactPartsFingerprint,
21
+ getContactMessage,
22
+ getLatestContactMessagePreview,
23
+ getLatestContactMessagePreviews,
16
24
  insertContactMessage,
17
25
  listContactMessages,
18
26
  listInFlightWatches,
19
27
  listWatchesBySession,
20
28
  nextContactOrdinal,
29
+ projectActiveContactTurn,
21
30
  updateSessionCardStatus,
22
31
  upsertContactWatch,
23
32
  } from './contact-store.js';
33
+ import {
34
+ CONTACT_ATTACHMENT_TURN_BUDGET_BYTES,
35
+ assertContactAttachmentOwned,
36
+ assertContactAttachmentReadable,
37
+ canonicalDescriptorFromRow,
38
+ ensureContactAttachmentSchema,
39
+ getContactAttachmentRow,
40
+ ingestDataUrlFilePart,
41
+ isSafeInlineImageMime,
42
+ materializeContactHistory,
43
+ materializeContactParts,
44
+ migrateContactDataUrlParts,
45
+ putContactAttachment,
46
+ readContactAttachmentBytes,
47
+ } from './contact-attachments.js';
24
48
  import {
25
49
  ASSIGNED_SESSION_FALLBACK_BUBBLE,
26
50
  boundSessionListLimit,
@@ -31,15 +55,25 @@ import {
31
55
  filterRegisteredProjects,
32
56
  matchesProjectQuery,
33
57
  NEW_CONVERSATION_CONFIRM_BUBBLE,
58
+ normalizeConnectedModels,
34
59
  normalizeRegisteredProjects,
35
60
  sanitizeRegisteredProject,
36
- userTextAuthorizesClearChatHistory,
37
61
  } from './contact-tools.js';
38
- import { AssignError, ASSIGN_CODES, assignSession, resolveAssignDirectory } from './assign.js';
62
+ import {
63
+ AssignError,
64
+ ASSIGN_CODES,
65
+ assignSession,
66
+ attachmentScopeKey,
67
+ hasAssignImageParts,
68
+ resolveAssignDirectory,
69
+ resolveAssignWorkerModel,
70
+ sanitizeAssignFileParts,
71
+ } from './assign.js';
39
72
  import { runContactTurn as defaultRunContactTurn } from './harness.js';
73
+ import { loadConnectedCatalog } from '../llm/catalog.js';
40
74
 
41
75
  const require = createRequire(import.meta.url);
42
- const SCHEMA_VERSION = 11;
76
+ const SCHEMA_VERSION = 12;
43
77
  const normalizeSessionDirectory = (value) => {
44
78
  if (typeof value !== 'string') return null;
45
79
  const trimmed = value.trim();
@@ -92,6 +126,25 @@ const isTransientMessagesFailure = (result, error) => {
92
126
  };
93
127
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
94
128
  const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
129
+ /** Bound catalog / project list waits so a contact lane cannot hang forever. */
130
+ export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
131
+ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
132
+ let timer = null;
133
+ try {
134
+ return await Promise.race([
135
+ Promise.resolve().then(() => work),
136
+ new Promise((_, reject) => {
137
+ timer = setTimeout(() => {
138
+ const error = new Error(code);
139
+ error.code = code;
140
+ reject(error);
141
+ }, ms);
142
+ }),
143
+ ]);
144
+ } finally {
145
+ if (timer) clearTimeout(timer);
146
+ }
147
+ };
95
148
 
96
149
  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 } = {}) => {
97
150
  if (!dbPath || !dataDir) return null;
@@ -100,6 +153,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
100
153
  const db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON');
101
154
  let closed = false;
102
155
  const shareReservations = new Map();
156
+ // Process-local contact-turn activity. Not durable across server restart —
157
+ // a new process starts empty so working cannot stick green forever. APP
158
+ // restart rehydrates from snapshot while this process still holds the turn.
159
+ const activeContactTurnsByAssistant = new Map();
103
160
  db.exec(`CREATE TABLE IF NOT EXISTS assistant_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
104
161
  CREATE TABLE IF NOT EXISTS assistant_v2 (assistant_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, enabled INTEGER NOT NULL, name TEXT NOT NULL, default_prompt TEXT NOT NULL, workspace_path TEXT, provider_id TEXT NOT NULL, model_id TEXT NOT NULL, agent TEXT, variant TEXT, mode TEXT NOT NULL DEFAULT 'continuous', current_session_id TEXT, session_generation INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, tombstone_at INTEGER);
105
162
  CREATE TABLE IF NOT EXISTS assistant_share_operation (operation_id TEXT PRIMARY KEY, assistant_id TEXT NOT NULL, payload_hash TEXT NOT NULL, phase TEXT NOT NULL, session_id TEXT, message_id TEXT, state TEXT NOT NULL, response TEXT, error_code TEXT, attempt INTEGER NOT NULL DEFAULT 0, lease_expires_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
@@ -124,6 +181,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
124
181
  CREATE INDEX IF NOT EXISTS assistant_scheduled_task_assistant
125
182
  ON assistant_scheduled_task(assistant_id, created_at DESC);`);
126
183
  ensureContactSchema(db);
184
+ ensureContactAttachmentSchema(db);
185
+ // Background legacy data-URL → descriptor migration (cancelable; never touches foreign DBs).
186
+ const contactAttachmentMigration = { controller: new AbortController(), promise: null };
187
+ contactAttachmentMigration.promise = Promise.resolve().then(() => migrateContactDataUrlParts({
188
+ db,
189
+ dataDir,
190
+ signal: contactAttachmentMigration.controller.signal,
191
+ yieldFn: () => new Promise((resolve) => setImmediateFn(resolve)),
192
+ })).catch((error) => {
193
+ if (error?.name !== 'AbortError' && !closed) {
194
+ // Diagnostics only — never throw into boot.
195
+ console.warn('[assistants] contact attachment migration:', error?.code || error?.message || error);
196
+ }
197
+ return { migrated: 0, failed: 0, scanned: 0, failures: [], error: error?.code || error?.message };
198
+ });
127
199
  const historyColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_session_history')").all().map((column) => column.name));
128
200
  if (!historyColumns.has('directory')) db.exec('ALTER TABLE assistant_session_history ADD COLUMN directory TEXT');
129
201
  const mirrorColumns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant_message_mirror')").all().map((column) => column.name));
@@ -168,8 +240,55 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
168
240
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
169
241
  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);
170
242
  };
171
- const output = (row) => {
243
+ const activeContactTurnsFor = (assistantID) => activeContactTurnsByAssistant.get(assistantID) ?? new Map();
244
+ const rememberActiveContactTurn = (assistantID, input) => {
245
+ const next = createActiveContactTurn(input);
246
+ if (!next) return null;
247
+ const turns = activeContactTurnsFor(assistantID);
248
+ turns.set(next.turnID, next);
249
+ activeContactTurnsByAssistant.set(assistantID, turns);
250
+ return next;
251
+ };
252
+ const markActiveContactTurnRunning = (assistantID, turnID) => {
253
+ const turns = activeContactTurnsFor(assistantID);
254
+ const current = turns.get(turnID);
255
+ if (!current) return null;
256
+ if (current.status === 'running') return current;
257
+ const next = createActiveContactTurn({ ...current, status: 'running' });
258
+ if (!next) return null;
259
+ turns.set(turnID, next);
260
+ activeContactTurnsByAssistant.set(assistantID, turns);
261
+ // queued → running must tip snapshot so clients poll authoritative status.
262
+ bump();
263
+ return next;
264
+ };
265
+ const settleActiveContactTurn = (assistantID, turnID, { bumpRevision = false } = {}) => {
266
+ const turns = activeContactTurnsFor(assistantID);
267
+ if (!turns.has(turnID)) {
268
+ if (bumpRevision) bump();
269
+ return false;
270
+ }
271
+ turns.delete(turnID);
272
+ if (turns.size === 0) activeContactTurnsByAssistant.delete(assistantID);
273
+ else activeContactTurnsByAssistant.set(assistantID, turns);
274
+ // Error paths may not persist assistant rows; still tip the snapshot so
275
+ // list green dots clear without waiting for a later SQLite write.
276
+ if (bumpRevision) bump();
277
+ return true;
278
+ };
279
+ const clearActiveContactTurns = (assistantID) => {
280
+ if (assistantID == null) {
281
+ activeContactTurnsByAssistant.clear();
282
+ return;
283
+ }
284
+ activeContactTurnsByAssistant.delete(assistantID);
285
+ };
286
+ const output = (row, latestMessagePreview) => {
172
287
  const watches = listInFlightWatches(db, row.assistant_id);
288
+ const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
289
+ const preview = latestMessagePreview !== undefined
290
+ ? latestMessagePreview
291
+ : getLatestContactMessagePreview(db, row.assistant_id);
173
292
  return {
174
293
  id: row.assistant_id,
175
294
  revision: row.revision,
@@ -189,7 +308,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
189
308
  historySessionIDs: historyIDs(row.assistant_id),
190
309
  historySessionCount: historyCount(row.assistant_id),
191
310
  assignedSessionIDs: watches.map((watch) => watch.sessionID),
192
- working: watches.some((watch) => watch.status === 'busy'),
311
+ // Grok-Bot list green dot: contact turn only. Assigned-session busy stays
312
+ // on the session card / assignedSessionIDs and must not keep the avatar lit.
313
+ working: activeContactTurn != null,
314
+ activeContactTurn,
315
+ // Contact-transcript list desc: authoritative last visible bubble (not
316
+ // defaultPrompt / updatedAt). null when the contact has no list-visible rows.
317
+ latestMessagePreview: preview ?? null,
193
318
  createdAt: row.created_at,
194
319
  updatedAt: row.updated_at,
195
320
  tombstoneAt: row.tombstone_at,
@@ -242,7 +367,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
242
367
  const restoreOnce = async (row, expectedSessionID, expectedGeneration) => { for (let attempt = 0; attempt < 3; attempt++) { const current = active(row.assistant_id); if (current.current_session_id !== expectedSessionID || current.session_generation !== expectedGeneration) return binding(current); const created = await createSession(current); const won = replaceBinding(current, created); if (won) return binding(won); } return binding(active(row.assistant_id)); };
243
368
  const configuration = (row) => ({ model: { providerID: row.provider_id, modelID: row.model_id }, ...(row.agent ? { agent: row.agent } : {}), ...(row.variant ? { variant: row.variant } : {}), ...(row.default_prompt ? { system: row.default_prompt } : {}) });
244
369
  const capturedConfiguration = (target) => ({ model: { providerID: target.providerID, modelID: target.modelID }, ...(target.agent ? { agent: target.agent } : {}), ...(target.variant ? { variant: target.variant } : {}), ...(target.system ? { system: target.system } : target.defaultPrompt ? { system: target.defaultPrompt } : {}) });
245
- const validateParts = (parts) => { if (!validAssistantDeliveryParts(parts)) fail('validation_error'); };
370
+ const validateParts = (parts) => {
371
+ if (!validAssistantDeliveryParts(parts, { allowAttachmentRefs: true })) fail('validation_error');
372
+ };
246
373
  const migrate = () => {
247
374
  if (db.prepare("SELECT value FROM assistant_meta WHERE key='schema_version'").get()?.value === String(SCHEMA_VERSION)) return;
248
375
  const v2Info = db.prepare("SELECT name,\"notnull\" AS required FROM pragma_table_info('assistant_v2')").all(); const v2Columns = new Set(v2Info.map((column) => column.name));
@@ -638,13 +765,60 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
638
765
  if (text) return text;
639
766
  return input.parts.some((part) => part?.type === 'file') ? '[attachment]' : '';
640
767
  };
641
- const userContactParts = (input, userText) => {
768
+ const userContactParts = async (assistantID, input, userText) => {
642
769
  const parts = [];
770
+ let turnBytes = 0;
643
771
  if (Array.isArray(input.parts)) {
644
772
  for (const part of input.parts) {
645
773
  const parsed = parseContactPart(part);
646
- if (parsed?.type === 'text' && parsed.text.trim()) parts.push({ type: 'text', text: parsed.text });
647
- if (parsed?.type === 'file') parts.push(parsed);
774
+ if (parsed?.type === 'text' && parsed.text.trim()) {
775
+ parts.push({ type: 'text', text: parsed.text });
776
+ continue;
777
+ }
778
+ if (parsed?.type !== 'file') continue;
779
+ if (parsed.attachmentID) {
780
+ let row;
781
+ try {
782
+ // Admission: current-generation ownership + canonical row rebuild (ignore client meta).
783
+ row = assertContactAttachmentReadable(db, { attachmentID: parsed.attachmentID, assistantID });
784
+ } catch (error) {
785
+ fail(error?.code === 'not_found' ? 'not_found' : 'validation_error', error?.message);
786
+ }
787
+ const descriptor = canonicalDescriptorFromRow(row);
788
+ turnBytes += descriptor.size || 0;
789
+ if (turnBytes > CONTACT_ATTACHMENT_TURN_BUDGET_BYTES) {
790
+ fail('validation_error', 'Contact attachment total exceeds 50MiB budget');
791
+ }
792
+ parts.push(descriptor);
793
+ continue;
794
+ }
795
+ if (typeof parsed.url === 'string' && parsed.url.startsWith('data:')) {
796
+ try {
797
+ const descriptor = await ingestDataUrlFilePart({
798
+ db,
799
+ dataDir,
800
+ assistantID,
801
+ part: parsed,
802
+ clock: now,
803
+ mode: 'admission',
804
+ });
805
+ // Re-check generation after await.
806
+ const row = assertContactAttachmentReadable(db, {
807
+ attachmentID: descriptor.attachmentID,
808
+ assistantID,
809
+ });
810
+ const canonical = canonicalDescriptorFromRow(row);
811
+ turnBytes += canonical.size || 0;
812
+ if (turnBytes > CONTACT_ATTACHMENT_TURN_BUDGET_BYTES) {
813
+ fail('validation_error', 'Contact attachment total exceeds 50MiB budget');
814
+ }
815
+ parts.push(canonical);
816
+ } catch (error) {
817
+ fail(error?.code || 'validation_error', error?.message);
818
+ }
819
+ continue;
820
+ }
821
+ fail('validation_error', 'File parts require attachmentID or data URL');
648
822
  }
649
823
  }
650
824
  if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
@@ -672,6 +846,31 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
672
846
  throw error;
673
847
  }
674
848
  };
849
+ /** Durable failure bubble so APP restart can recover error without SSE. */
850
+ const persistContactTurnFailure = (assistantID, { userMessageID, turnID, error }) => {
851
+ const detail = typeof error === 'string' && error.trim() ? error.trim() : 'upstream_error';
852
+ const failureID = `${userMessageID}:error`;
853
+ if (getContactMessage(db, failureID)) return;
854
+ db.exec('BEGIN IMMEDIATE');
855
+ try {
856
+ insertContactMessage(db, {
857
+ messageID: failureID,
858
+ assistantID,
859
+ role: 'assistant',
860
+ turnID,
861
+ bubbleIndex: 0,
862
+ createdAt: now(),
863
+ ordinal: nextContactOrdinal(db, assistantID),
864
+ status: 'error',
865
+ parts: [{ type: 'text', text: detail }],
866
+ });
867
+ bump();
868
+ db.exec('COMMIT');
869
+ } catch (writeError) {
870
+ db.exec('ROLLBACK');
871
+ throw writeError;
872
+ }
873
+ };
675
874
  /** Persist assistant bubbles/cards for an already-admitted user turn (no user row). */
676
875
  const persistContactAssistantReply = (assistantID, { userMessageID, bubbles, cards = [], turnID }) => {
677
876
  db.exec('BEGIN IMMEDIATE');
@@ -778,17 +977,138 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
778
977
  const resolveContactTurnSettlement = (messageID, result) => {
779
978
  contactTurnSettlement(messageID).resolve(result);
780
979
  };
781
- const assignWork = (row, params) => assignSession({
782
- ...params,
783
- assistant: output(row),
784
- defaultProjectPath: row.workspace_path,
785
- allowedRoots: getAllowedRoots(),
786
- managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
787
- listWorktrees,
788
- createSession: (created) => client().session.create(created),
789
- promptExisting: (prompted) => client().session.promptAsync(prompted),
790
- messageID: params?.messageID || `msg_assign_${id()}`,
791
- });
980
+ const loadAssignCatalog = async (signal) => {
981
+ try {
982
+ // Catalog owns its 8s AbortSignal deadline; optional parent signal is forwarded.
983
+ return await loadConnectedCatalog(client(), {
984
+ timeoutMs: CONTACT_CATALOG_DEADLINE_MS,
985
+ ...(signal ? { signal } : {}),
986
+ });
987
+ } catch (error) {
988
+ const wrapped = new AssignError(
989
+ ASSIGN_CODES.UPSTREAM,
990
+ typeof error?.message === 'string' && error.message.trim()
991
+ ? error.message.trim()
992
+ : 'Connected model catalog is unavailable.',
993
+ );
994
+ throw wrapped;
995
+ }
996
+ };
997
+ const loadRegisteredProjects = async () => {
998
+ const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
999
+ if (!Array.isArray(listed)) {
1000
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
1001
+ }
1002
+ return listed;
1003
+ };
1004
+ /**
1005
+ * Confirm whether an assign prompt message was admitted on the worker session.
1006
+ * Prefer SDK exact lookup (v2.session.message → session.message), then bounded messages list.
1007
+ * Returns { found: true|false } for assignSession; throws/unavailable become null via assign.
1008
+ */
1009
+ const lookupAssignMessage = async ({ sessionID, messageID, directory, signal } = {}) => {
1010
+ if (typeof sessionID !== 'string' || !sessionID || typeof messageID !== 'string' || !messageID) {
1011
+ return { found: false };
1012
+ }
1013
+ const api = client();
1014
+ const options = signal ? { signal } : undefined;
1015
+ const identity = (record) => (
1016
+ record?.info?.id || record?.id || record?.messageID || record?.message?.id || null
1017
+ );
1018
+ const viaExact = async (fn, target) => {
1019
+ if (typeof fn !== 'function') return null;
1020
+ try {
1021
+ const result = await fn.call(target, { sessionID, messageID, ...(directory ? { directory } : {}) }, options);
1022
+ const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status ?? result?.response?.status;
1023
+ if (result?.error) {
1024
+ if (status === 404) return { found: false };
1025
+ if (status === 405 || status === 501) return null;
1026
+ return null;
1027
+ }
1028
+ const record = result?.data ?? result;
1029
+ const id = identity(record);
1030
+ return { found: id === messageID };
1031
+ } catch {
1032
+ return null;
1033
+ }
1034
+ };
1035
+ const v2 = await viaExact(api?.v2?.session?.message, api?.v2?.session);
1036
+ if (v2) return v2;
1037
+ const legacy = await viaExact(api?.session?.message, api?.session);
1038
+ if (legacy) return legacy;
1039
+ try {
1040
+ const listed = await api.session.messages({
1041
+ sessionID,
1042
+ ...(directory ? { directory } : {}),
1043
+ limit: 100,
1044
+ }, options);
1045
+ if (listed?.error) return { found: false };
1046
+ const rows = Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []);
1047
+ const hit = rows.some((row) => identity(row) === messageID);
1048
+ return { found: hit };
1049
+ } catch {
1050
+ return null;
1051
+ }
1052
+ };
1053
+ const assignWork = async (row, params = {}) => {
1054
+ const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
1055
+ const signal = params.signal;
1056
+ const sdkOptions = signal ? { signal } : undefined;
1057
+ const explicitModel = Boolean(
1058
+ (typeof params.providerID === 'string' && params.providerID.trim())
1059
+ || (typeof params.modelID === 'string' && params.modelID.trim())
1060
+ || (typeof params.model === 'string' && params.model.trim())
1061
+ || (params.model && typeof params.model === 'object'),
1062
+ );
1063
+ const needsCatalog = explicitModel || hasAssignImageParts(fileParts);
1064
+ let catalog = null;
1065
+ if (needsCatalog) {
1066
+ catalog = await loadAssignCatalog(signal);
1067
+ }
1068
+ const workerModel = resolveAssignWorkerModel({
1069
+ providerID: params.providerID,
1070
+ modelID: params.modelID,
1071
+ model: params.model,
1072
+ fallback: { providerID: row.provider_id, modelID: row.model_id },
1073
+ catalog,
1074
+ });
1075
+ let acceptsImages = workerModel.acceptsImages;
1076
+ if (hasAssignImageParts(fileParts) && acceptsImages == null && catalog) {
1077
+ const entry = catalog.models.find((item) => (
1078
+ item?.providerID === workerModel.providerID && item?.modelID === workerModel.modelID
1079
+ ));
1080
+ acceptsImages = entry?.acceptsImages === true;
1081
+ }
1082
+ // Worker model is prompt-only. Never mutate the contact assistant row.
1083
+ // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
1084
+ return assignSession({
1085
+ ...params,
1086
+ fileParts,
1087
+ ...(params.variant !== undefined ? { variant: params.variant } : {}),
1088
+ ...(signal ? { signal } : {}),
1089
+ model: {
1090
+ providerID: workerModel.providerID,
1091
+ modelID: workerModel.modelID,
1092
+ source: workerModel.source,
1093
+ acceptsImages,
1094
+ },
1095
+ acceptsImages,
1096
+ assistant: output(row),
1097
+ defaultProjectPath: row.workspace_path,
1098
+ allowedRoots: getAllowedRoots(),
1099
+ managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
1100
+ listWorktrees,
1101
+ createSession: (created) => client().session.create(created, sdkOptions),
1102
+ promptExisting: (prompted) => client().session.promptAsync(prompted, sdkOptions),
1103
+ deleteSession: (deleted) => (
1104
+ typeof client().session.delete === 'function'
1105
+ ? client().session.delete(deleted, sdkOptions)
1106
+ : undefined
1107
+ ),
1108
+ lookupMessage: (lookup) => lookupAssignMessage({ ...lookup, signal }),
1109
+ messageID: params?.messageID || `msg_assign_${id()}`,
1110
+ });
1111
+ };
792
1112
  const matchRegisteredProject = async (directory) => {
793
1113
  const projects = await listProjects();
794
1114
  const resolved = path.resolve(directory);
@@ -1029,16 +1349,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1029
1349
  const messageID = string(input.messageID, 256, true);
1030
1350
  const userText = extractUserText(input);
1031
1351
  if (!userText) fail('validation_error');
1032
- const userParts = userContactParts(input, userText);
1033
1352
  const row = active(assistantID);
1353
+ const userParts = await userContactParts(row.assistant_id, input, userText);
1034
1354
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1035
1355
  let registeredProjects = [];
1036
1356
  try {
1037
- const listed = await listProjects();
1038
- if (!Array.isArray(listed)) {
1039
- throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
1040
- }
1041
- registeredProjects = filterRegisteredProjects(listed);
1357
+ registeredProjects = filterRegisteredProjects(await loadRegisteredProjects());
1042
1358
  } catch (error) {
1043
1359
  if (error instanceof AssignError || error instanceof AssistantError) throw error;
1044
1360
  // Still allow the turn — tools can surface a later catalog failure.
@@ -1046,22 +1362,49 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1046
1362
  }
1047
1363
  const turnID = messageID;
1048
1364
  const assistantSnapshot = output(row);
1365
+ const existingUser = getContactMessage(db, messageID);
1366
+ if (existingUser) {
1367
+ if (existingUser.assistantID !== row.assistant_id || existingUser.role !== 'user') fail('validation_error');
1368
+ const existingFingerprint = contactPartsFingerprint(existingUser.parts);
1369
+ const nextFingerprint = contactPartsFingerprint(
1370
+ Array.isArray(userParts) && userParts.length > 0 ? userParts : [{ type: 'text', text: userText }],
1371
+ );
1372
+ if (existingFingerprint !== nextFingerprint) fail('idempotency_conflict');
1373
+ // Same messageID + payload: replay admission only — never start a second lane turn.
1374
+ return {
1375
+ binding: binding(row),
1376
+ messageID,
1377
+ admitted: true,
1378
+ revision: revision(),
1379
+ replayed: true,
1380
+ };
1381
+ }
1049
1382
  admitContactUser(row.assistant_id, {
1050
1383
  userMessageID: messageID,
1051
1384
  userText,
1052
1385
  userParts,
1053
1386
  turnID,
1054
1387
  });
1388
+ rememberActiveContactTurn(row.assistant_id, {
1389
+ turnID,
1390
+ messageID,
1391
+ status: 'queued',
1392
+ admittedAt: now(),
1393
+ });
1055
1394
  emitContactTurnEvent('openchamber:contact-turn-start', {
1056
1395
  assistantID: row.assistant_id,
1057
1396
  turnID,
1058
1397
  messageID,
1059
1398
  });
1060
1399
  contactTurnSettlement(messageID);
1061
- // Authorize wipe from this turn's real userText only — not tool args.
1062
- const clearChatHistoryAuthorized = userTextAuthorizesClearChatHistory(userText);
1063
1400
  const kickTurn = () => {
1064
1401
  void inContactTurnLane(row.assistant_id, async () => {
1402
+ if (closed) {
1403
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1404
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1405
+ return;
1406
+ }
1407
+ markActiveContactTurnRunning(row.assistant_id, turnID);
1065
1408
  // Load the LLM window when the lane runs — not at admit time — so a
1066
1409
  // prior turn's clear-memory cannot leave queued work with stale history.
1067
1410
  // Exclude the admitted user row; prompt still receives userText/parts.
@@ -1078,54 +1421,78 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1078
1421
  const assignedCards = [];
1079
1422
  let contactResetThisTurn = false;
1080
1423
  let contactHistoryClearedThisTurn = false;
1081
- const tools = createContactTools({
1082
- assignWork: (params) => assignWork(row, params),
1083
- createAssistant: (toolInput) => createAssistant(toolInput),
1084
- scheduleTask: (params) => scheduleWork(row, params),
1085
- deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
1086
- clearContactMemory: () => {
1087
- // Cap the watermark at this turn's user ordinal — never global MAX,
1088
- // or later-admitted queued users are permanently excluded from LLM history.
1089
- const result = clearContactMemory(row.assistant_id, {
1090
- upToOrdinal: beforeOrdinal,
1091
- });
1092
- contactResetThisTurn = true;
1093
- return result;
1094
- },
1095
- resetContact: () => {
1096
- // Server core gate: model mis-select cannot self-authorize delete.
1097
- if (!clearChatHistoryAuthorized) {
1098
- throw new AssignError(
1099
- ASSIGN_CODES.VALIDATION,
1100
- 'clear_chat_history requires explicit user wipe intent in this message.',
1101
- );
1102
- }
1103
- const result = resetContact(row.assistant_id);
1104
- contactResetThisTurn = true;
1105
- contactHistoryClearedThisTurn = true;
1106
- return result;
1107
- },
1108
- listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1109
- listProjects: async () => {
1110
- const listed = await listProjects();
1111
- if (!Array.isArray(listed)) {
1112
- throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
1113
- }
1114
- return listed;
1115
- },
1116
- listSessions: (params) => listSessionsWork(params),
1117
- currentAssistant: assistantSnapshot,
1118
- onCard: (card) => assignedCards.push(card),
1119
- });
1424
+ let connectedModels = [];
1120
1425
  try {
1426
+ const catalog = await loadAssignCatalog(undefined);
1427
+ connectedModels = normalizeConnectedModels(catalog?.models);
1428
+ } catch {
1429
+ // Catalog failure must not block default-model contact turns.
1430
+ // Explicit worker model / image capability checks still fail closed inside assignWork.
1431
+ connectedModels = [];
1432
+ }
1433
+ try {
1434
+ // Materialize inside turn try so failures durable-error + settle working.
1435
+ // DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
1436
+ const executionHistory = await materializeContactHistory(
1437
+ db,
1438
+ dataDir,
1439
+ row.assistant_id,
1440
+ history,
1441
+ { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
1442
+ );
1443
+ const executionParts = await materializeContactParts(
1444
+ db,
1445
+ dataDir,
1446
+ row.assistant_id,
1447
+ userParts,
1448
+ { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
1449
+ );
1450
+ // Current-turn attachments only — never unbounded history image forwarding.
1451
+ const turnFileParts = sanitizeAssignFileParts(
1452
+ (Array.isArray(executionParts) ? executionParts : []).filter((part) => part?.type === 'file'),
1453
+ );
1454
+ const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1455
+ const tools = createContactTools({
1456
+ assignWork: (params) => assignWork(row, params),
1457
+ createAssistant: (toolInput) => createAssistant(toolInput),
1458
+ scheduleTask: (params) => scheduleWork(row, params),
1459
+ deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
1460
+ clearContactMemory: () => {
1461
+ // Cap the watermark at this turn's user ordinal — never global MAX,
1462
+ // or later-admitted queued users are permanently excluded from LLM history.
1463
+ const result = clearContactMemory(row.assistant_id, {
1464
+ upToOrdinal: beforeOrdinal,
1465
+ });
1466
+ contactResetThisTurn = true;
1467
+ return result;
1468
+ },
1469
+ resetContact: () => {
1470
+ // Tool wipe: keep wipe user + later-admitted users only; delete all
1471
+ // other roles at any ordinal (incl. late prior-lane assistant/cards).
1472
+ const result = resetContact(row.assistant_id, {
1473
+ upToOrdinal: beforeOrdinal,
1474
+ });
1475
+ contactResetThisTurn = true;
1476
+ contactHistoryClearedThisTurn = true;
1477
+ return result;
1478
+ },
1479
+ listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1480
+ listProjects: async () => loadRegisteredProjects(),
1481
+ listSessions: (params) => listSessionsWork(params),
1482
+ currentAssistant: assistantSnapshot,
1483
+ onCard: (card) => assignedCards.push(card),
1484
+ turnFileParts,
1485
+ turnAttachmentScope,
1486
+ });
1121
1487
  const generated = await runContactTurn({
1122
1488
  assistant: assistantSnapshot,
1123
- history,
1489
+ history: executionHistory,
1124
1490
  userText,
1125
- userParts,
1491
+ userParts: executionParts,
1126
1492
  createChatCompletion,
1127
1493
  tools,
1128
1494
  projects: registeredProjects,
1495
+ connectedModels,
1129
1496
  globalEventHub,
1130
1497
  onBubbleDelta: (bubbleIndex, delta, done) => {
1131
1498
  emitContactTurnEvent('openchamber:contact-bubble-delta', {
@@ -1138,6 +1505,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1138
1505
  },
1139
1506
  });
1140
1507
  if (closed) {
1508
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1141
1509
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1142
1510
  return;
1143
1511
  }
@@ -1159,6 +1527,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1159
1527
  ].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
1160
1528
  if (bubbles.length === 0 && cards.length === 0) {
1161
1529
  const detail = 'Assistant returned no text';
1530
+ try {
1531
+ persistContactTurnFailure(row.assistant_id, {
1532
+ userMessageID: messageID,
1533
+ turnID,
1534
+ error: detail,
1535
+ });
1536
+ settleActiveContactTurn(row.assistant_id, turnID);
1537
+ } catch {
1538
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1539
+ }
1162
1540
  emitContactTurnEvent('openchamber:contact-turn-end', {
1163
1541
  assistantID: row.assistant_id,
1164
1542
  turnID,
@@ -1175,9 +1553,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1175
1553
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
1176
1554
  return;
1177
1555
  }
1178
- // clear_chat_history / resetContact wipe the transcript (including the
1179
- // admitted user row). Re-admit that user message, then persist only the
1180
- // confirm bubble. new_conversation keeps rows and only moves the watermark.
1556
+ // clear_chat_history keeps the wipe user row (bounded delete). Re-admit
1557
+ // only if the row is missing (unbounded API wipe / edge). Then persist
1558
+ // only the confirm bubble. new_conversation keeps all rows + watermark.
1181
1559
  if (resetThisTurn) {
1182
1560
  const existingUser = db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(messageID);
1183
1561
  if (!existingUser) {
@@ -1195,6 +1573,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1195
1573
  cards,
1196
1574
  turnID,
1197
1575
  });
1576
+ // persistContactAssistantReply already bumps; clear activity without a second tip.
1577
+ settleActiveContactTurn(row.assistant_id, turnID);
1198
1578
  emitContactTurnEvent('openchamber:contact-turn-end', {
1199
1579
  assistantID: row.assistant_id,
1200
1580
  turnID,
@@ -1214,6 +1594,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1214
1594
  ? error.message.trim()
1215
1595
  : (error?.code || 'upstream_error');
1216
1596
  const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
1597
+ try {
1598
+ persistContactTurnFailure(row.assistant_id, {
1599
+ userMessageID: messageID,
1600
+ turnID,
1601
+ error: detail,
1602
+ });
1603
+ settleActiveContactTurn(row.assistant_id, turnID);
1604
+ } catch {
1605
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1606
+ }
1217
1607
  emitContactTurnEvent('openchamber:contact-turn-end', {
1218
1608
  assistantID: row.assistant_id,
1219
1609
  turnID,
@@ -1234,13 +1624,35 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1234
1624
  };
1235
1625
  // Defer past send() resolution so callers observe admission before assistant rows.
1236
1626
  setImmediateFn(kickTurn);
1237
- return { binding: binding(row), messageID, admitted: true };
1627
+ return { binding: binding(row), messageID, admitted: true, revision: revision() };
1238
1628
  };
1239
1629
  const contactMessages = (assistantID, query = {}) => {
1240
- editable(assistantID);
1241
- const limit = query.limit == null ? 50 : Number(query.limit);
1242
- if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error');
1243
- return listContactMessages(db, assistantID, { before: query.before, limit });
1630
+ const row = editable(assistantID);
1631
+ const hasBefore = !(query.before == null || query.before === '');
1632
+ const hasExact = !(query.messageID == null || query.messageID === '');
1633
+ if (hasBefore && hasExact) fail('validation_error', 'before and messageID are mutually exclusive');
1634
+ let limit = query.limit == null || query.limit === '' ? CONTACT_PAGE_DEFAULT_LIMIT : Number(query.limit);
1635
+ if (!Number.isInteger(limit) || limit < 1 || limit > CONTACT_PAGE_MAX_LIMIT) fail('validation_error');
1636
+ let page;
1637
+ try {
1638
+ page = listContactMessages(db, row.assistant_id, {
1639
+ before: hasBefore ? query.before : undefined,
1640
+ limit,
1641
+ messageID: hasExact ? query.messageID : undefined,
1642
+ });
1643
+ } catch (error) {
1644
+ if (error?.code === 'contact_generation_conflict' || error?.code === 'validation_error') {
1645
+ fail(error.code, error.message);
1646
+ }
1647
+ throw error;
1648
+ }
1649
+ return {
1650
+ messages: page.messages,
1651
+ nextCursor: page.nextCursor,
1652
+ complete: page.complete,
1653
+ generation: page.generation,
1654
+ revision: revision(),
1655
+ };
1244
1656
  };
1245
1657
  /**
1246
1658
  * Clear LLM memory only: advance the durable context boundary so later
@@ -1267,21 +1679,34 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1267
1679
  };
1268
1680
  /**
1269
1681
  * Delete this assistant's OpenChamber contact transcript (messages, parts,
1270
- * watches) and clear the context boundary. Intentional wipe only — also the
1271
- * POST /contact/reset contract. Does not call OpenCode session/new.
1682
+ * watches) and clear the context boundary. Also the POST /contact/reset
1683
+ * contract. Does not call OpenCode session/new.
1684
+ *
1685
+ * Tool path may pass `upToOrdinal` (wiping turn's user ordinal) so later-
1686
+ * admitted queued rows survive. Direct API without a ceiling deletes every
1687
+ * row (existing contract). Concurrent in-flight admissions already past 202
1688
+ * keep their message IDs only when bounded; unbounded API wipe drops them
1689
+ * and a later same-ID send replays as a fresh admission only if the row is gone.
1272
1690
  */
1273
- const resetContact = (assistantID) => {
1691
+ const resetContact = (assistantID, options = {}) => {
1274
1692
  const row = editable(assistantID);
1693
+ const upToOrdinal = options && typeof options === 'object' ? options.upToOrdinal : undefined;
1275
1694
  db.exec('BEGIN IMMEDIATE');
1276
1695
  try {
1277
- deleteContactMessages(db, row.assistant_id);
1696
+ // Generation bumps only on transcript wipe — not on clear-memory.
1697
+ const generation = bumpContactGeneration(db, row.assistant_id);
1698
+ deleteContactMessages(db, row.assistant_id, {
1699
+ ...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
1700
+ });
1701
+ // Active contact turns stay until settle — wipe must not drop the green
1702
+ // dot while the clearing turn is still running.
1278
1703
  bump();
1279
1704
  db.exec('COMMIT');
1705
+ return { assistantID: row.assistant_id, reset: true, historyCleared: true, generation };
1280
1706
  } catch (error) {
1281
1707
  db.exec('ROLLBACK');
1282
1708
  throw error;
1283
1709
  }
1284
- return { assistantID: row.assistant_id, reset: true, historyCleared: true };
1285
1710
  };
1286
1711
  const appendContactCard = (assistantID, input) => {
1287
1712
  const row = active(assistantID);
@@ -1419,7 +1844,57 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1419
1844
  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); };
1420
1845
  const timer = setIntervalFn(() => { if (!closed) { db.prepare('DELETE FROM assistant_share_operation WHERE updated_at<?').run(now() - SHARE_RETENTION_MS); return reconcile(); } }, reconcileIntervalMs);
1421
1846
  void reconcile();
1422
- 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) => {
1847
+ const putAssistantContactAttachment = async (assistantID, uploadID, { stream, headers, signal, storeBytes } = {}) => {
1848
+ const row = editable(assistantID);
1849
+ try {
1850
+ return await putContactAttachment({
1851
+ db,
1852
+ dataDir,
1853
+ assistantID: row.assistant_id,
1854
+ uploadID,
1855
+ stream,
1856
+ headers,
1857
+ clock: now,
1858
+ signal,
1859
+ ...(storeBytes ? { storeBytes } : {}),
1860
+ });
1861
+ } catch (error) {
1862
+ if (error?.code) fail(error.code, error.message);
1863
+ throw error;
1864
+ }
1865
+ };
1866
+ const getAssistantContactAttachment = async (assistantID, attachmentID) => {
1867
+ const row = editable(assistantID);
1868
+ try {
1869
+ return await readContactAttachmentBytes({
1870
+ db,
1871
+ dataDir,
1872
+ assistantID: row.assistant_id,
1873
+ attachmentID,
1874
+ external: true,
1875
+ });
1876
+ } catch (error) {
1877
+ if (error?.code) fail(error.code, error.message);
1878
+ throw error;
1879
+ }
1880
+ };
1881
+ const migrateContactAttachments = async (assistantID = null) => migrateContactDataUrlParts({
1882
+ db,
1883
+ dataDir,
1884
+ assistantID,
1885
+ clock: now,
1886
+ });
1887
+ const snapshot = () => {
1888
+ const rows = db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all();
1889
+ // Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
1890
+ const previews = getLatestContactMessagePreviews(db, rows.map((row) => row.assistant_id));
1891
+ return {
1892
+ revision: revision(),
1893
+ enabled: enabled(),
1894
+ assistants: rows.map((row) => output(row, previews.get(row.assistant_id) ?? null)),
1895
+ };
1896
+ };
1897
+ 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) => {
1423
1898
  db.exec('BEGIN IMMEDIATE');
1424
1899
  try {
1425
1900
  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);
@@ -1430,6 +1905,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1430
1905
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
1431
1906
  db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
1432
1907
  deleteContactMessages(db, assistantID);
1908
+ clearActiveContactTurns(assistantID);
1433
1909
  bump();
1434
1910
  db.exec('COMMIT');
1435
1911
  return { assistantID, tombstoneAt: now() };
@@ -1437,5 +1913,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1437
1913
  db.exec('ROLLBACK');
1438
1914
  throw error;
1439
1915
  }
1440
- }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => { if (!closed) { closed = true; unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
1916
+ }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
1917
+ if (!closed) {
1918
+ closed = true;
1919
+ try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
1920
+ clearActiveContactTurns();
1921
+ unsubscribeEvents?.();
1922
+ clearIntervalFn(timer);
1923
+ db.close();
1924
+ }
1925
+ } };
1441
1926
  };