@openchambery/web 1.19.11 → 1.19.13-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
  2. package/dist/assets/AssistantView-YmeyxehG.js +2 -0
  3. package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
  5. package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
  6. package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
  8. package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
  9. package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
  10. package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
  11. package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
  12. package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
  15. package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
  16. package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
  17. package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
  20. package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
  21. package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
  22. package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
  23. package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
  24. package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
  26. package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
  27. package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
  28. package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
  29. package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
  30. package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
  31. package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
  32. package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
  33. package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
  34. package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
  35. package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
  36. package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
  37. package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
  38. package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
  39. package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
  40. package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
  41. package/dist/assets/miniChat-B9c3y2pD.js +2 -0
  42. package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
  43. package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
  44. package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
  45. package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
  46. package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
  47. package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
  48. package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
  49. package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
  50. package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
  51. package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
  52. package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
  53. package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
  54. package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
  55. package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
  56. package/dist/assets/src-BpHDMgud.css +2 -0
  57. package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
  58. package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
  59. package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
  60. package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
  61. package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
  62. package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
  63. package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
  64. package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
  65. package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
  66. package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
  67. package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
  68. package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
  69. package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
  70. package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
  71. package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
  72. package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
  73. package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
  74. package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
  75. package/dist/index.html +9 -9
  76. package/dist/mini-chat.html +9 -9
  77. package/dist/mobile.html +9 -9
  78. package/package.json +1 -1
  79. package/server/index.js +19 -0
  80. package/server/lib/assistants/DOCUMENTATION.md +21 -6
  81. package/server/lib/assistants/assign.js +455 -30
  82. package/server/lib/assistants/assign.test.js +351 -0
  83. package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
  84. package/server/lib/assistants/contact-store.js +64 -0
  85. package/server/lib/assistants/contact-store.test.js +12 -0
  86. package/server/lib/assistants/contact-tools.js +216 -25
  87. package/server/lib/assistants/contact-tools.test.js +167 -0
  88. package/server/lib/assistants/contracts.js +2 -2
  89. package/server/lib/assistants/harness.js +20 -3
  90. package/server/lib/assistants/harness.test.js +120 -1
  91. package/server/lib/assistants/pi-tools.js +50 -3
  92. package/server/lib/assistants/pi-tools.test.js +25 -0
  93. package/server/lib/assistants/service.js +320 -27
  94. package/server/lib/assistants/service.test.js +581 -9
  95. package/server/lib/llm/DOCUMENTATION.md +31 -18
  96. package/server/lib/llm/catalog.js +91 -16
  97. package/server/lib/llm/catalog.test.js +152 -11
  98. package/server/lib/llm/generate.js +43 -7
  99. package/server/lib/llm/generate.test.js +98 -1
  100. package/server/lib/llm/temp-directory.js +34 -13
  101. package/server/lib/llm/temp-directory.test.js +57 -0
  102. package/server/lib/notifications/APNS.md +20 -4
  103. package/server/lib/notifications/DOCUMENTATION.md +5 -0
  104. package/server/lib/notifications/apns-runtime.js +136 -18
  105. package/server/lib/notifications/apns-runtime.test.js +128 -0
  106. package/server/lib/notifications/apns-titles.js +21 -1
  107. package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
  108. package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
  109. package/server/lib/notifications/runtime.js +93 -2
  110. package/server/lib/notifications/runtime.test.js +167 -4
  111. package/server/lib/opencode/settings-helpers.js +12 -0
  112. package/server/lib/opencode/settings-runtime.js +16 -0
  113. package/server/lib/opencode/settings-runtime.test.js +21 -0
  114. package/server/lib/scheduled-tasks/runtime.js +21 -0
  115. package/server/lib/scheduled-tasks/runtime.test.js +24 -0
  116. package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
  117. package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
  118. package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
  119. package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
  120. package/dist/assets/miniChat-BJ9AclZX.js +0 -2
  121. package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
  122. package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
  123. package/dist/assets/src-B-P5qIBc.css +0 -2
@@ -11,13 +11,17 @@ import {
11
11
  CONTACT_SETTLE_TEXT,
12
12
  clearContactMemory as clearContactMemoryStore,
13
13
  contactHistoryForLlm,
14
+ createActiveContactTurn,
14
15
  deleteContactMessages,
15
16
  ensureContactSchema,
17
+ contactPartsFingerprint,
18
+ getContactMessage,
16
19
  insertContactMessage,
17
20
  listContactMessages,
18
21
  listInFlightWatches,
19
22
  listWatchesBySession,
20
23
  nextContactOrdinal,
24
+ projectActiveContactTurn,
21
25
  updateSessionCardStatus,
22
26
  upsertContactWatch,
23
27
  } from './contact-store.js';
@@ -31,12 +35,23 @@ import {
31
35
  filterRegisteredProjects,
32
36
  matchesProjectQuery,
33
37
  NEW_CONVERSATION_CONFIRM_BUBBLE,
38
+ normalizeConnectedModels,
34
39
  normalizeRegisteredProjects,
35
40
  sanitizeRegisteredProject,
36
41
  userTextAuthorizesClearChatHistory,
37
42
  } from './contact-tools.js';
38
- import { AssignError, ASSIGN_CODES, assignSession, resolveAssignDirectory } from './assign.js';
43
+ import {
44
+ AssignError,
45
+ ASSIGN_CODES,
46
+ assignSession,
47
+ attachmentScopeKey,
48
+ hasAssignImageParts,
49
+ resolveAssignDirectory,
50
+ resolveAssignWorkerModel,
51
+ sanitizeAssignFileParts,
52
+ } from './assign.js';
39
53
  import { runContactTurn as defaultRunContactTurn } from './harness.js';
54
+ import { loadConnectedCatalog } from '../llm/catalog.js';
40
55
 
41
56
  const require = createRequire(import.meta.url);
42
57
  const SCHEMA_VERSION = 11;
@@ -92,6 +107,25 @@ const isTransientMessagesFailure = (result, error) => {
92
107
  };
93
108
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
94
109
  const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
110
+ /** Bound catalog / project list waits so a contact lane cannot hang forever. */
111
+ export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
112
+ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
113
+ let timer = null;
114
+ try {
115
+ return await Promise.race([
116
+ Promise.resolve().then(() => work),
117
+ new Promise((_, reject) => {
118
+ timer = setTimeout(() => {
119
+ const error = new Error(code);
120
+ error.code = code;
121
+ reject(error);
122
+ }, ms);
123
+ }),
124
+ ]);
125
+ } finally {
126
+ if (timer) clearTimeout(timer);
127
+ }
128
+ };
95
129
 
96
130
  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
131
  if (!dbPath || !dataDir) return null;
@@ -100,6 +134,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
100
134
  const db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.pragma('foreign_keys = ON');
101
135
  let closed = false;
102
136
  const shareReservations = new Map();
137
+ // Process-local contact-turn activity. Not durable across server restart —
138
+ // a new process starts empty so working cannot stick green forever. APP
139
+ // restart rehydrates from snapshot while this process still holds the turn.
140
+ const activeContactTurnsByAssistant = new Map();
103
141
  db.exec(`CREATE TABLE IF NOT EXISTS assistant_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
104
142
  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
143
  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);
@@ -168,8 +206,52 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
168
206
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
169
207
  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
208
  };
209
+ const activeContactTurnsFor = (assistantID) => activeContactTurnsByAssistant.get(assistantID) ?? new Map();
210
+ const rememberActiveContactTurn = (assistantID, input) => {
211
+ const next = createActiveContactTurn(input);
212
+ if (!next) return null;
213
+ const turns = activeContactTurnsFor(assistantID);
214
+ turns.set(next.turnID, next);
215
+ activeContactTurnsByAssistant.set(assistantID, turns);
216
+ return next;
217
+ };
218
+ const markActiveContactTurnRunning = (assistantID, turnID) => {
219
+ const turns = activeContactTurnsFor(assistantID);
220
+ const current = turns.get(turnID);
221
+ if (!current) return null;
222
+ if (current.status === 'running') return current;
223
+ const next = createActiveContactTurn({ ...current, status: 'running' });
224
+ if (!next) return null;
225
+ turns.set(turnID, next);
226
+ activeContactTurnsByAssistant.set(assistantID, turns);
227
+ // queued → running must tip snapshot so clients poll authoritative status.
228
+ bump();
229
+ return next;
230
+ };
231
+ const settleActiveContactTurn = (assistantID, turnID, { bumpRevision = false } = {}) => {
232
+ const turns = activeContactTurnsFor(assistantID);
233
+ if (!turns.has(turnID)) {
234
+ if (bumpRevision) bump();
235
+ return false;
236
+ }
237
+ turns.delete(turnID);
238
+ if (turns.size === 0) activeContactTurnsByAssistant.delete(assistantID);
239
+ else activeContactTurnsByAssistant.set(assistantID, turns);
240
+ // Error paths may not persist assistant rows; still tip the snapshot so
241
+ // list green dots clear without waiting for a later SQLite write.
242
+ if (bumpRevision) bump();
243
+ return true;
244
+ };
245
+ const clearActiveContactTurns = (assistantID) => {
246
+ if (assistantID == null) {
247
+ activeContactTurnsByAssistant.clear();
248
+ return;
249
+ }
250
+ activeContactTurnsByAssistant.delete(assistantID);
251
+ };
171
252
  const output = (row) => {
172
253
  const watches = listInFlightWatches(db, row.assistant_id);
254
+ const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
173
255
  return {
174
256
  id: row.assistant_id,
175
257
  revision: row.revision,
@@ -189,7 +271,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
189
271
  historySessionIDs: historyIDs(row.assistant_id),
190
272
  historySessionCount: historyCount(row.assistant_id),
191
273
  assignedSessionIDs: watches.map((watch) => watch.sessionID),
192
- working: watches.some((watch) => watch.status === 'busy'),
274
+ // Grok-Bot list green dot: contact turn only. Assigned-session busy stays
275
+ // on the session card / assignedSessionIDs and must not keep the avatar lit.
276
+ working: activeContactTurn != null,
277
+ activeContactTurn,
193
278
  createdAt: row.created_at,
194
279
  updatedAt: row.updated_at,
195
280
  tombstoneAt: row.tombstone_at,
@@ -672,6 +757,31 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
672
757
  throw error;
673
758
  }
674
759
  };
760
+ /** Durable failure bubble so APP restart can recover error without SSE. */
761
+ const persistContactTurnFailure = (assistantID, { userMessageID, turnID, error }) => {
762
+ const detail = typeof error === 'string' && error.trim() ? error.trim() : 'upstream_error';
763
+ const failureID = `${userMessageID}:error`;
764
+ if (getContactMessage(db, failureID)) return;
765
+ db.exec('BEGIN IMMEDIATE');
766
+ try {
767
+ insertContactMessage(db, {
768
+ messageID: failureID,
769
+ assistantID,
770
+ role: 'assistant',
771
+ turnID,
772
+ bubbleIndex: 0,
773
+ createdAt: now(),
774
+ ordinal: nextContactOrdinal(db, assistantID),
775
+ status: 'error',
776
+ parts: [{ type: 'text', text: detail }],
777
+ });
778
+ bump();
779
+ db.exec('COMMIT');
780
+ } catch (writeError) {
781
+ db.exec('ROLLBACK');
782
+ throw writeError;
783
+ }
784
+ };
675
785
  /** Persist assistant bubbles/cards for an already-admitted user turn (no user row). */
676
786
  const persistContactAssistantReply = (assistantID, { userMessageID, bubbles, cards = [], turnID }) => {
677
787
  db.exec('BEGIN IMMEDIATE');
@@ -778,17 +888,138 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
778
888
  const resolveContactTurnSettlement = (messageID, result) => {
779
889
  contactTurnSettlement(messageID).resolve(result);
780
890
  };
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
- });
891
+ const loadAssignCatalog = async (signal) => {
892
+ try {
893
+ // Catalog owns its 8s AbortSignal deadline; optional parent signal is forwarded.
894
+ return await loadConnectedCatalog(client(), {
895
+ timeoutMs: CONTACT_CATALOG_DEADLINE_MS,
896
+ ...(signal ? { signal } : {}),
897
+ });
898
+ } catch (error) {
899
+ const wrapped = new AssignError(
900
+ ASSIGN_CODES.UPSTREAM,
901
+ typeof error?.message === 'string' && error.message.trim()
902
+ ? error.message.trim()
903
+ : 'Connected model catalog is unavailable.',
904
+ );
905
+ throw wrapped;
906
+ }
907
+ };
908
+ const loadRegisteredProjects = async () => {
909
+ const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
910
+ if (!Array.isArray(listed)) {
911
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Registered project catalog failed to load.');
912
+ }
913
+ return listed;
914
+ };
915
+ /**
916
+ * Confirm whether an assign prompt message was admitted on the worker session.
917
+ * Prefer SDK exact lookup (v2.session.message → session.message), then bounded messages list.
918
+ * Returns { found: true|false } for assignSession; throws/unavailable become null via assign.
919
+ */
920
+ const lookupAssignMessage = async ({ sessionID, messageID, directory, signal } = {}) => {
921
+ if (typeof sessionID !== 'string' || !sessionID || typeof messageID !== 'string' || !messageID) {
922
+ return { found: false };
923
+ }
924
+ const api = client();
925
+ const options = signal ? { signal } : undefined;
926
+ const identity = (record) => (
927
+ record?.info?.id || record?.id || record?.messageID || record?.message?.id || null
928
+ );
929
+ const viaExact = async (fn, target) => {
930
+ if (typeof fn !== 'function') return null;
931
+ try {
932
+ const result = await fn.call(target, { sessionID, messageID, ...(directory ? { directory } : {}) }, options);
933
+ const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status ?? result?.response?.status;
934
+ if (result?.error) {
935
+ if (status === 404) return { found: false };
936
+ if (status === 405 || status === 501) return null;
937
+ return null;
938
+ }
939
+ const record = result?.data ?? result;
940
+ const id = identity(record);
941
+ return { found: id === messageID };
942
+ } catch {
943
+ return null;
944
+ }
945
+ };
946
+ const v2 = await viaExact(api?.v2?.session?.message, api?.v2?.session);
947
+ if (v2) return v2;
948
+ const legacy = await viaExact(api?.session?.message, api?.session);
949
+ if (legacy) return legacy;
950
+ try {
951
+ const listed = await api.session.messages({
952
+ sessionID,
953
+ ...(directory ? { directory } : {}),
954
+ limit: 100,
955
+ }, options);
956
+ if (listed?.error) return { found: false };
957
+ const rows = Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []);
958
+ const hit = rows.some((row) => identity(row) === messageID);
959
+ return { found: hit };
960
+ } catch {
961
+ return null;
962
+ }
963
+ };
964
+ const assignWork = async (row, params = {}) => {
965
+ const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
966
+ const signal = params.signal;
967
+ const sdkOptions = signal ? { signal } : undefined;
968
+ const explicitModel = Boolean(
969
+ (typeof params.providerID === 'string' && params.providerID.trim())
970
+ || (typeof params.modelID === 'string' && params.modelID.trim())
971
+ || (typeof params.model === 'string' && params.model.trim())
972
+ || (params.model && typeof params.model === 'object'),
973
+ );
974
+ const needsCatalog = explicitModel || hasAssignImageParts(fileParts);
975
+ let catalog = null;
976
+ if (needsCatalog) {
977
+ catalog = await loadAssignCatalog(signal);
978
+ }
979
+ const workerModel = resolveAssignWorkerModel({
980
+ providerID: params.providerID,
981
+ modelID: params.modelID,
982
+ model: params.model,
983
+ fallback: { providerID: row.provider_id, modelID: row.model_id },
984
+ catalog,
985
+ });
986
+ let acceptsImages = workerModel.acceptsImages;
987
+ if (hasAssignImageParts(fileParts) && acceptsImages == null && catalog) {
988
+ const entry = catalog.models.find((item) => (
989
+ item?.providerID === workerModel.providerID && item?.modelID === workerModel.modelID
990
+ ));
991
+ acceptsImages = entry?.acceptsImages === true;
992
+ }
993
+ // Worker model is prompt-only. Never mutate the contact assistant row.
994
+ // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
995
+ return assignSession({
996
+ ...params,
997
+ fileParts,
998
+ ...(params.variant !== undefined ? { variant: params.variant } : {}),
999
+ ...(signal ? { signal } : {}),
1000
+ model: {
1001
+ providerID: workerModel.providerID,
1002
+ modelID: workerModel.modelID,
1003
+ source: workerModel.source,
1004
+ acceptsImages,
1005
+ },
1006
+ acceptsImages,
1007
+ assistant: output(row),
1008
+ defaultProjectPath: row.workspace_path,
1009
+ allowedRoots: getAllowedRoots(),
1010
+ managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
1011
+ listWorktrees,
1012
+ createSession: (created) => client().session.create(created, sdkOptions),
1013
+ promptExisting: (prompted) => client().session.promptAsync(prompted, sdkOptions),
1014
+ deleteSession: (deleted) => (
1015
+ typeof client().session.delete === 'function'
1016
+ ? client().session.delete(deleted, sdkOptions)
1017
+ : undefined
1018
+ ),
1019
+ lookupMessage: (lookup) => lookupAssignMessage({ ...lookup, signal }),
1020
+ messageID: params?.messageID || `msg_assign_${id()}`,
1021
+ });
1022
+ };
792
1023
  const matchRegisteredProject = async (directory) => {
793
1024
  const projects = await listProjects();
794
1025
  const resolved = path.resolve(directory);
@@ -1034,11 +1265,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1034
1265
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1035
1266
  let registeredProjects = [];
1036
1267
  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);
1268
+ registeredProjects = filterRegisteredProjects(await loadRegisteredProjects());
1042
1269
  } catch (error) {
1043
1270
  if (error instanceof AssignError || error instanceof AssistantError) throw error;
1044
1271
  // Still allow the turn — tools can surface a later catalog failure.
@@ -1046,12 +1273,35 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1046
1273
  }
1047
1274
  const turnID = messageID;
1048
1275
  const assistantSnapshot = output(row);
1276
+ const existingUser = getContactMessage(db, messageID);
1277
+ if (existingUser) {
1278
+ if (existingUser.assistantID !== row.assistant_id || existingUser.role !== 'user') fail('validation_error');
1279
+ const existingFingerprint = contactPartsFingerprint(existingUser.parts);
1280
+ const nextFingerprint = contactPartsFingerprint(
1281
+ Array.isArray(userParts) && userParts.length > 0 ? userParts : [{ type: 'text', text: userText }],
1282
+ );
1283
+ if (existingFingerprint !== nextFingerprint) fail('idempotency_conflict');
1284
+ // Same messageID + payload: replay admission only — never start a second lane turn.
1285
+ return {
1286
+ binding: binding(row),
1287
+ messageID,
1288
+ admitted: true,
1289
+ revision: revision(),
1290
+ replayed: true,
1291
+ };
1292
+ }
1049
1293
  admitContactUser(row.assistant_id, {
1050
1294
  userMessageID: messageID,
1051
1295
  userText,
1052
1296
  userParts,
1053
1297
  turnID,
1054
1298
  });
1299
+ rememberActiveContactTurn(row.assistant_id, {
1300
+ turnID,
1301
+ messageID,
1302
+ status: 'queued',
1303
+ admittedAt: now(),
1304
+ });
1055
1305
  emitContactTurnEvent('openchamber:contact-turn-start', {
1056
1306
  assistantID: row.assistant_id,
1057
1307
  turnID,
@@ -1062,6 +1312,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1062
1312
  const clearChatHistoryAuthorized = userTextAuthorizesClearChatHistory(userText);
1063
1313
  const kickTurn = () => {
1064
1314
  void inContactTurnLane(row.assistant_id, async () => {
1315
+ if (closed) {
1316
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1317
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1318
+ return;
1319
+ }
1320
+ markActiveContactTurnRunning(row.assistant_id, turnID);
1065
1321
  // Load the LLM window when the lane runs — not at admit time — so a
1066
1322
  // prior turn's clear-memory cannot leave queued work with stale history.
1067
1323
  // Exclude the admitted user row; prompt still receives userText/parts.
@@ -1078,6 +1334,20 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1078
1334
  const assignedCards = [];
1079
1335
  let contactResetThisTurn = false;
1080
1336
  let contactHistoryClearedThisTurn = false;
1337
+ // Current-turn attachments only — never unbounded history image forwarding.
1338
+ const turnFileParts = sanitizeAssignFileParts(
1339
+ (Array.isArray(userParts) ? userParts : []).filter((part) => part?.type === 'file'),
1340
+ );
1341
+ const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1342
+ let connectedModels = [];
1343
+ try {
1344
+ const catalog = await loadAssignCatalog(undefined);
1345
+ connectedModels = normalizeConnectedModels(catalog?.models);
1346
+ } catch {
1347
+ // Catalog failure must not block default-model contact turns.
1348
+ // Explicit worker model / image capability checks still fail closed inside assignWork.
1349
+ connectedModels = [];
1350
+ }
1081
1351
  const tools = createContactTools({
1082
1352
  assignWork: (params) => assignWork(row, params),
1083
1353
  createAssistant: (toolInput) => createAssistant(toolInput),
@@ -1106,16 +1376,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1106
1376
  return result;
1107
1377
  },
1108
1378
  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
- },
1379
+ listProjects: async () => loadRegisteredProjects(),
1116
1380
  listSessions: (params) => listSessionsWork(params),
1117
1381
  currentAssistant: assistantSnapshot,
1118
1382
  onCard: (card) => assignedCards.push(card),
1383
+ turnFileParts,
1384
+ turnAttachmentScope,
1119
1385
  });
1120
1386
  try {
1121
1387
  const generated = await runContactTurn({
@@ -1126,6 +1392,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1126
1392
  createChatCompletion,
1127
1393
  tools,
1128
1394
  projects: registeredProjects,
1395
+ connectedModels,
1129
1396
  globalEventHub,
1130
1397
  onBubbleDelta: (bubbleIndex, delta, done) => {
1131
1398
  emitContactTurnEvent('openchamber:contact-bubble-delta', {
@@ -1138,6 +1405,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1138
1405
  },
1139
1406
  });
1140
1407
  if (closed) {
1408
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1141
1409
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1142
1410
  return;
1143
1411
  }
@@ -1159,6 +1427,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1159
1427
  ].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
1160
1428
  if (bubbles.length === 0 && cards.length === 0) {
1161
1429
  const detail = 'Assistant returned no text';
1430
+ try {
1431
+ persistContactTurnFailure(row.assistant_id, {
1432
+ userMessageID: messageID,
1433
+ turnID,
1434
+ error: detail,
1435
+ });
1436
+ settleActiveContactTurn(row.assistant_id, turnID);
1437
+ } catch {
1438
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1439
+ }
1162
1440
  emitContactTurnEvent('openchamber:contact-turn-end', {
1163
1441
  assistantID: row.assistant_id,
1164
1442
  turnID,
@@ -1195,6 +1473,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1195
1473
  cards,
1196
1474
  turnID,
1197
1475
  });
1476
+ // persistContactAssistantReply already bumps; clear activity without a second tip.
1477
+ settleActiveContactTurn(row.assistant_id, turnID);
1198
1478
  emitContactTurnEvent('openchamber:contact-turn-end', {
1199
1479
  assistantID: row.assistant_id,
1200
1480
  turnID,
@@ -1214,6 +1494,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1214
1494
  ? error.message.trim()
1215
1495
  : (error?.code || 'upstream_error');
1216
1496
  const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
1497
+ try {
1498
+ persistContactTurnFailure(row.assistant_id, {
1499
+ userMessageID: messageID,
1500
+ turnID,
1501
+ error: detail,
1502
+ });
1503
+ settleActiveContactTurn(row.assistant_id, turnID);
1504
+ } catch {
1505
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1506
+ }
1217
1507
  emitContactTurnEvent('openchamber:contact-turn-end', {
1218
1508
  assistantID: row.assistant_id,
1219
1509
  turnID,
@@ -1234,7 +1524,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1234
1524
  };
1235
1525
  // Defer past send() resolution so callers observe admission before assistant rows.
1236
1526
  setImmediateFn(kickTurn);
1237
- return { binding: binding(row), messageID, admitted: true };
1527
+ return { binding: binding(row), messageID, admitted: true, revision: revision() };
1238
1528
  };
1239
1529
  const contactMessages = (assistantID, query = {}) => {
1240
1530
  editable(assistantID);
@@ -1275,6 +1565,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1275
1565
  db.exec('BEGIN IMMEDIATE');
1276
1566
  try {
1277
1567
  deleteContactMessages(db, row.assistant_id);
1568
+ // Active contact turns stay until settle — wipe must not drop the green
1569
+ // dot while the clearing turn is still running.
1278
1570
  bump();
1279
1571
  db.exec('COMMIT');
1280
1572
  } catch (error) {
@@ -1430,6 +1722,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1430
1722
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
1431
1723
  db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
1432
1724
  deleteContactMessages(db, assistantID);
1725
+ clearActiveContactTurns(assistantID);
1433
1726
  bump();
1434
1727
  db.exec('COMMIT');
1435
1728
  return { assistantID, tombstoneAt: now() };
@@ -1437,5 +1730,5 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1437
1730
  db.exec('ROLLBACK');
1438
1731
  throw error;
1439
1732
  }
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(); } } };
1733
+ }, 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; clearActiveContactTurns(); unsubscribeEvents?.(); clearIntervalFn(timer); db.close(); } } };
1441
1734
  };