@openchambery/web 1.19.13 → 1.19.14

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 (117) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-Dncbu_Z2.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-CovEl1BY.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/Icon-D1BcR8Tz.js +1 -0
  6. package/dist/assets/{MarkdownRenderer-CLQ4Z8Z0.js → MarkdownRenderer-CZqok-Cf.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-CCfHY-86.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-y6QgIUZA.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-Ca-d5eZ9.js +1 -0
  10. package/dist/assets/MobileShareBridge-B4S7BaUj.js +4 -0
  11. package/dist/assets/MobileSurface-xL5Jby4U.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-BeHa-CG6.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-bIWeMLVx.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/SettingsView-Dn7odtQz.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-C0tWPS5f.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-BnJgC7Qo.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-D9S-X8hM.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-DBrvaJnq.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-GUUNlWIv.js} +1 -1
  22. package/dist/assets/{collapsible-CACHBRYZ.js → collapsible-Bbz9KiJf.js} +1 -1
  23. package/dist/assets/{context-menu-CYbm4wBZ.js → context-menu-CSyOhRz8.js} +1 -1
  24. package/dist/assets/{desktopHosts-DILvrAok.js → desktopHosts-B052a4UK.js} +1 -1
  25. package/dist/assets/{desktopRelayRestore-t8asCr3m.js → desktopRelayRestore-OjKNXr10.js} +1 -1
  26. package/dist/assets/{diagram-u0BC9uuY.js → diagram-NdsZih8P.js} +1 -1
  27. package/dist/assets/{dialog-BwQ3rY_x.js → dialog-DhMGbF_B.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BQb6I8gl.js} +2 -2
  29. package/dist/assets/fileMentionTouchSelection-Dc004xOh.js +4 -0
  30. package/dist/assets/{fileQueries-Bgly-E8f.js → fileQueries-B_s_B-fH.js} +1 -1
  31. package/dist/assets/{fr-D9taEN9L.js → fr-BjGouC_l.js} +2 -2
  32. package/dist/assets/{gitApi-DtPf-h-O.js → gitApi-Cm3JLQqh.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-CTWYFsbH.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-Cw9zliYY.js} +2 -2
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-Dyz0Dk9b.js} +2 -2
  37. package/dist/assets/main-CSqu3MB4.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-DwMprEyr.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-C8bzY_Ye.js +2 -0
  41. package/dist/assets/mobile-Oy-SRKkr.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-ClcuaiKU.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-C7EY-dTH.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-cw9nrwpr.js → projectMeta-F93wE2Ye.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-rOOhMVlQ.js +2 -0
  49. package/dist/assets/renderMobileApp-BP_hGwHP.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-iJ2mTBVN.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-ISnhAS3q.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BAzU2J1H.js +2 -0
  53. package/dist/assets/sessionLookup-DQQ9-DuP.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-DYeppLfP.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-ClnJdJSj.js} +3 -3
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-B9CmN13Z.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DgG8v2md.js} +2 -2
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-BDFUND4v.js → useAppFontEffects--IcU7jyv.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-BqJ5R_T2.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-ChWXV8xM.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-DQkgxuzg.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-DWWAvQQ0.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-D2oQszsk.js} +1 -1
  69. package/dist/assets/{utils-BHTVlVov.js → utils-2ZK8yboQ.js} +1 -1
  70. package/dist/assets/{vendor-reactuses-core-DDoDDyM9.js → vendor-reactuses-core-BnW6GxJt.js} +1 -1
  71. package/dist/assets/{zh-CN-C96qP1ip.js → zh-CN-C6OFKjeT.js} +2 -2
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-BVP99aMr.js} +2 -2
  73. package/dist/index.html +8 -8
  74. package/dist/mini-chat.html +8 -8
  75. package/dist/mobile.html +8 -8
  76. package/package.json +1 -1
  77. package/server/index.js +1 -0
  78. package/server/lib/assistants/DOCUMENTATION.md +93 -16
  79. package/server/lib/assistants/assign.js +117 -12
  80. package/server/lib/assistants/assign.test.js +79 -0
  81. package/server/lib/assistants/bubbles.js +1 -1
  82. package/server/lib/assistants/contact-store.js +496 -11
  83. package/server/lib/assistants/contact-store.test.js +323 -7
  84. package/server/lib/assistants/contact-tools.js +303 -52
  85. package/server/lib/assistants/contact-tools.test.js +239 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +178 -50
  88. package/server/lib/assistants/harness.test.js +428 -3
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +586 -43
  91. package/server/lib/assistants/service.test.js +696 -7
  92. package/server/lib/llm/DOCUMENTATION.md +2 -0
  93. package/server/lib/llm/completions.js +4 -0
  94. package/server/lib/llm/completions.test.js +3 -0
  95. package/server/lib/llm/generate.js +12 -5
  96. package/server/lib/llm/generate.test.js +40 -0
  97. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  98. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  99. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  100. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  101. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  102. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  103. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  104. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  105. package/dist/assets/main-BjeOhdcG.js +0 -169
  106. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  107. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  108. package/dist/assets/platform-DzsGqlGW.js +0 -2
  109. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  110. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  111. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  112. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  113. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  114. package/dist/assets/src-AA-5nXsF.css +0 -2
  115. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  116. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  117. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -10,22 +10,29 @@ import { contactCardIdentity, parseContactCard, parseContactPart } from './cards
10
10
  import {
11
11
  CONTACT_PAGE_DEFAULT_LIMIT,
12
12
  CONTACT_PAGE_MAX_LIMIT,
13
+ advanceContactReadWatermark,
13
14
  bumpContactGeneration,
14
15
  clearContactMemory as clearContactMemoryStore,
15
16
  contactHistoryForLlm,
16
17
  createActiveContactTurn,
17
18
  deleteContactMessages,
19
+ deleteContactReadState,
18
20
  ensureContactSchema,
19
21
  contactPartsFingerprint,
20
22
  getContactMessage,
23
+ getContactReadTip,
24
+ getContactReadWatermark,
25
+ getContactUnreadSnapshots,
21
26
  getLatestContactMessagePreview,
22
27
  getLatestContactMessagePreviews,
23
28
  insertContactMessage,
24
29
  listContactMessages,
25
30
  listInFlightWatches,
26
31
  listWatchesBySession,
32
+ migrateContactReadStateDefaultRead,
27
33
  nextContactOrdinal,
28
34
  projectActiveContactTurn,
35
+ resetContactReadWatermarkForGeneration,
29
36
  updateSessionCardStatus,
30
37
  upsertContactWatch,
31
38
  } from './contact-store.js';
@@ -63,7 +70,10 @@ import {
63
70
  ASSIGN_CODES,
64
71
  assignSession,
65
72
  attachmentScopeKey,
73
+ extractSessionDirectory,
74
+ extractSessionTitle,
66
75
  hasAssignImageParts,
76
+ mapSessionToWatchStatus,
67
77
  resolveAssignDirectory,
68
78
  extractAssignSessionModel,
69
79
  resolveAssignWorkerModel,
@@ -73,7 +83,7 @@ import { runContactTurn as defaultRunContactTurn } from './harness.js';
73
83
  import { loadConnectedCatalog } from '../llm/catalog.js';
74
84
 
75
85
  const require = createRequire(import.meta.url);
76
- const SCHEMA_VERSION = 12;
86
+ const SCHEMA_VERSION = 13;
77
87
  const normalizeSessionDirectory = (value) => {
78
88
  if (typeof value !== 'string') return null;
79
89
  const trimmed = value.trim();
@@ -152,7 +162,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
152
162
  }
153
163
  };
154
164
 
155
- 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 } = {}) => {
165
+ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
156
166
  if (!dbPath || !dataDir) return null;
157
167
  const Database = require('better-sqlite3');
158
168
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -289,12 +299,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
289
299
  }
290
300
  activeContactTurnsByAssistant.delete(assistantID);
291
301
  };
292
- const output = (row, latestMessagePreview) => {
302
+ const output = (row, latestMessagePreview, unreadSnapshot) => {
293
303
  const watches = listInFlightWatches(db, row.assistant_id);
294
304
  const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
295
305
  const preview = latestMessagePreview !== undefined
296
306
  ? latestMessagePreview
297
307
  : getLatestContactMessagePreview(db, row.assistant_id);
308
+ const unread = unreadSnapshot !== undefined && unreadSnapshot !== null
309
+ ? unreadSnapshot
310
+ : (getContactUnreadSnapshots(db, [row.assistant_id]).get(row.assistant_id) ?? {
311
+ unreadCount: 0,
312
+ readWatermark: getContactReadWatermark(db, row.assistant_id),
313
+ readTip: getContactReadTip(db, row.assistant_id),
314
+ });
298
315
  return {
299
316
  id: row.assistant_id,
300
317
  revision: row.revision,
@@ -321,6 +338,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
321
338
  // Contact-transcript list desc: authoritative last visible bubble (not
322
339
  // defaultPrompt / updatedAt). null when the contact has no list-visible rows.
323
340
  latestMessagePreview: preview ?? null,
341
+ // Shared multi-client unread: countable assistant/peer replies after watermark.
342
+ unreadCount: Number(unread.unreadCount) || 0,
343
+ // Persisted shared watermark (generation + keyset). Clients POST this tip safely.
344
+ readWatermark: unread.readWatermark,
345
+ // Highest readable tip for safe mark-read report (transcript head + generation).
346
+ readTip: unread.readTip,
324
347
  createdAt: row.created_at,
325
348
  updatedAt: row.updated_at,
326
349
  tombstoneAt: row.tombstone_at,
@@ -386,6 +409,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
386
409
  const legacy = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='assistant'").get();
387
410
  if (legacy) { const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('assistant')").all().map((column) => column.name)); for (const row of db.prepare('SELECT * FROM assistant').all()) { let sessionID = row.current_session_id ?? null; if (!sessionID && columns.has('inbox_topic_id') && row.inbox_topic_id) sessionID = db.prepare('SELECT session_id FROM assistant_topic WHERE topic_id=?').get(row.inbox_topic_id)?.session_id ?? null; if (!sessionID) sessionID = db.prepare("SELECT session_id FROM assistant_operation WHERE topic_id IN (SELECT topic_id FROM assistant_topic WHERE assistant_id=?) AND state='completed' AND session_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1").get(row.assistant_id)?.session_id ?? db.prepare('SELECT session_id FROM assistant_turn WHERE topic_id IN (SELECT topic_id FROM assistant_topic WHERE assistant_id=?) AND session_id IS NOT NULL ORDER BY created_at DESC LIMIT 1').get(row.assistant_id)?.session_id ?? null; const mode = columns.has('mode') && row.mode === 'stateless' ? 'stateless' : 'continuous'; db.prepare('INSERT OR IGNORE INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(row.assistant_id, row.revision, row.enabled, row.name, row.default_prompt, managedConfig(row.workspace_path, row.assistant_id), row.provider_id, row.model_id, row.agent, null, mode, sessionID, Math.max(0, row.session_generation ?? 0), row.created_at, row.updated_at, row.tombstone_at); } }
388
411
  for (const row of db.prepare('SELECT assistant_id,workspace_path FROM assistant_v2 WHERE workspace_path IS NOT NULL').all()) { const workspacePath = managedConfig(row.workspace_path, row.assistant_id); if (workspacePath === null) db.prepare('UPDATE assistant_v2 SET workspace_path=NULL WHERE assistant_id=?').run(row.assistant_id); }
412
+ // v13: shared contact read watermark. Legacy transcripts default to fully read
413
+ // so existing installs do not flash unread on upgrade; later messages count.
414
+ migrateContactReadStateDefaultRead(db, { updatedAt: now() });
389
415
  db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
390
416
  };
391
417
  migrate();
@@ -460,8 +486,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
460
486
  const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
461
487
  return nonEmptyString(sessionID) ? sessionID : '';
462
488
  };
489
+ const isUserAbort = (error) => error?.name === 'MessageAbortedError';
490
+ const assignedResumes = new Map();
491
+ const contactControllers = new Map();
492
+ // Last UI locale seen from a client per assistant. Background notifications
493
+ // (assigned-session settle) have no request locale, so they recall this value.
494
+ const contactTurnLanguages = new Map();
495
+ const cancelAssignedResumes = ({ sessionID, assistantID }) => {
496
+ for (const resume of assignedResumes.values()) {
497
+ if (sessionID && resume.sessionID !== sessionID) continue;
498
+ if (assistantID && resume.assistantID !== assistantID) continue;
499
+ resume.controller.abort();
500
+ }
501
+ };
463
502
  const settleStatusFromEvent = (payload, properties) => {
464
- if (payload.type === 'session.error') return 'error';
503
+ if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
465
504
  if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
466
505
  if (payload.type === 'session.idle') return 'complete';
467
506
  if (payload.type === 'session.status') {
@@ -475,6 +514,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
475
514
  // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
476
515
  const assignedSessionResumeRef = { schedule: null };
477
516
  const reportAssignedSession = (sessionID, status) => {
517
+ if (status === 'cancelled') cancelAssignedResumes({ sessionID });
478
518
  const watches = listWatchesBySession(db, sessionID);
479
519
  if (watches.length === 0) return false;
480
520
  let changed = false;
@@ -482,7 +522,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
482
522
  db.exec('BEGIN IMMEDIATE');
483
523
  try {
484
524
  for (const watch of watches) {
485
- if (watch.status === status) continue;
525
+ if (watch.status === status || watch.status === 'cancelled') continue;
486
526
  // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
487
527
  if (status === 'complete' && watch.status === 'error') continue;
488
528
  const updatedAt = now();
@@ -493,10 +533,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
493
533
  directory: watch.directory,
494
534
  status,
495
535
  updatedAt,
536
+ resumeAllowed: watch.resumeAllowed,
496
537
  });
497
538
  // complete/error: hand result back to contact LLM. question: card only (worker waiting).
498
539
  // Never insert oc.settle.* canned transcript bubbles.
499
- if (status === 'complete' || status === 'error') {
540
+ if (watch.resumeAllowed && (status === 'complete' || status === 'error')) {
500
541
  resumes.push({
501
542
  assistantID: watch.assistantID,
502
543
  sessionID,
@@ -537,12 +578,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
537
578
  if (isMissing(getResult)) return 'complete';
538
579
  if (getResult?.error) return null;
539
580
  const session = getResult?.data;
540
- if (session?.error) return 'error';
581
+ if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
541
582
  const statusType = sessionStatusType(session);
542
583
  if (statusType === 'busy' || statusType === 'retry') return null;
543
584
  if (isMissing(messagesResult)) return 'complete';
544
585
  const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
545
- if (assistant?.error) return 'error';
586
+ if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
546
587
  if (statusType === 'idle' || session?.time?.completed) return 'complete';
547
588
  if (assistant?.time?.completed) return 'complete';
548
589
  return null;
@@ -596,7 +637,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
596
637
  if (payload.type === 'message.updated') {
597
638
  const info = properties.info; const sessionID = info?.sessionID;
598
639
  if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
599
- const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { const current = assistant(assistantID)?.current_session_id === sessionID; mirrorMessage(assistantID, sessionID, info, undefined, current); if (!current) invalidateBackfill(assistantID, sessionID); } return assistants.length > 0;
640
+ const cancelled = info.role === 'assistant' && isUserAbort(info.error)
641
+ ? reportAssignedSession(sessionID, 'cancelled') : false;
642
+ const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { const current = assistant(assistantID)?.current_session_id === sessionID; mirrorMessage(assistantID, sessionID, info, undefined, current); if (!current) invalidateBackfill(assistantID, sessionID); } return cancelled || assistants.length > 0;
600
643
  }
601
644
  if (payload.type === 'message.part.updated') {
602
645
  const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
@@ -1005,6 +1048,23 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1005
1048
  throw wrapped;
1006
1049
  }
1007
1050
  };
1051
+ const loadContactModelContext = async (signal) => {
1052
+ const [catalogResult, preferencesResult] = await Promise.allSettled([
1053
+ loadAssignCatalog(signal),
1054
+ awaitWithDeadline(Promise.resolve().then(() => readModelPreferences()), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error'),
1055
+ ]);
1056
+ const catalog = catalogResult.status === 'fulfilled' ? catalogResult.value : null;
1057
+ const providerNames = new Map((catalog?.providers || []).map((provider) => [provider.id, provider.name]));
1058
+ const preferences = preferencesResult.status === 'fulfilled' ? preferencesResult.value : null;
1059
+ return {
1060
+ connectedModels: normalizeConnectedModels((catalog?.models || []).map((model) => ({ ...model, providerName: providerNames.get(model.providerID) }))),
1061
+ modelCatalogAvailable: catalog !== null,
1062
+ modelPreferences: preferences && typeof preferences === 'object' ? {
1063
+ favoriteModels: Array.isArray(preferences.favoriteModels) ? preferences.favoriteModels.slice(0, 64) : [],
1064
+ recentModels: Array.isArray(preferences.recentModels) ? preferences.recentModels.slice(0, 16) : [],
1065
+ } : null,
1066
+ };
1067
+ };
1008
1068
  const loadRegisteredProjects = async () => {
1009
1069
  const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
1010
1070
  if (!Array.isArray(listed)) {
@@ -1097,6 +1157,231 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1097
1157
  return null;
1098
1158
  }
1099
1159
  };
1160
+ /**
1161
+ * Find a session row in the OpenChamber session index (directory is index-authoritative).
1162
+ * Index failure throws — never silent empty.
1163
+ */
1164
+ const findSessionInIndex = (sessionID) => {
1165
+ if (!sessionIndexService || typeof sessionIndexService.snapshot !== 'function') {
1166
+ return null;
1167
+ }
1168
+ let snapshot;
1169
+ try {
1170
+ snapshot = sessionIndexService.snapshot();
1171
+ } catch (error) {
1172
+ throw new AssignError(
1173
+ ASSIGN_CODES.UPSTREAM,
1174
+ typeof error?.message === 'string' && error.message.trim()
1175
+ ? error.message.trim()
1176
+ : 'Session index snapshot failed.',
1177
+ );
1178
+ }
1179
+ if (!snapshot || !Array.isArray(snapshot.directories)) {
1180
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index snapshot failed.');
1181
+ }
1182
+ for (const directory of snapshot.directories) {
1183
+ const dir = typeof directory?.directory === 'string' ? directory.directory : '';
1184
+ if (!dir) continue;
1185
+ for (const session of Array.isArray(directory.sessions) ? directory.sessions : []) {
1186
+ if (!session || typeof session.id !== 'string' || session.id !== sessionID) continue;
1187
+ return {
1188
+ sessionID,
1189
+ directory: dir,
1190
+ title: typeof session.title === 'string' ? session.title : null,
1191
+ updatedAt: typeof session.time?.updated === 'number'
1192
+ ? session.time.updated
1193
+ : (typeof session.updatedAt === 'number' ? session.updatedAt : 0),
1194
+ };
1195
+ }
1196
+ }
1197
+ return null;
1198
+ };
1199
+ /**
1200
+ * Resolve an existing OpenCode session for watch / stop / assign-reuse.
1201
+ * Authority order: session index directory → session.get metadata → registered project.
1202
+ * User-supplied directory/projectPath is a lookup hint only — never trusted as scope.
1203
+ * Failures stay failures (not empty success).
1204
+ */
1205
+ const resolveReferencedSession = async ({
1206
+ sessionID: rawSessionID,
1207
+ directoryHint = null,
1208
+ titleHint = null,
1209
+ signal,
1210
+ includeMessages = true,
1211
+ } = {}) => {
1212
+ const sessionID = typeof rawSessionID === 'string' ? rawSessionID.trim() : '';
1213
+ if (!sessionID) {
1214
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'sessionID is required.');
1215
+ }
1216
+ const options = signal ? { signal } : undefined;
1217
+ const indexRow = findSessionInIndex(sessionID);
1218
+ const hint = typeof directoryHint === 'string' && directoryHint.trim()
1219
+ ? directoryHint.trim()
1220
+ : null;
1221
+ // Prefer index directory; user path is last-resort get hint only.
1222
+ const getHints = [];
1223
+ if (indexRow?.directory) getHints.push(indexRow.directory);
1224
+ if (hint && !getHints.some((item) => directoryContained(item, hint) || directoryContained(hint, item))) {
1225
+ getHints.push(hint);
1226
+ }
1227
+ if (getHints.length === 0) getHints.push(null);
1228
+
1229
+ let getResult = null;
1230
+ let usedDirectoryHint = null;
1231
+ let lastError = null;
1232
+ for (const dir of getHints) {
1233
+ try {
1234
+ getResult = await client().session.get({
1235
+ sessionID,
1236
+ ...(dir ? { directory: dir } : {}),
1237
+ }, options);
1238
+ usedDirectoryHint = dir;
1239
+ if (getResult && !getResult.error) break;
1240
+ if (isMissing(getResult)) {
1241
+ getResult = { missing: true, error: getResult?.error };
1242
+ break;
1243
+ }
1244
+ // Non-404 error with a directory may be wrong scope — try next hint.
1245
+ if (getResult?.error && dir) {
1246
+ lastError = getResult.error;
1247
+ getResult = null;
1248
+ continue;
1249
+ }
1250
+ break;
1251
+ } catch (error) {
1252
+ lastError = error;
1253
+ getResult = null;
1254
+ }
1255
+ }
1256
+ if (!getResult) {
1257
+ throw new AssignError(
1258
+ ASSIGN_CODES.UPSTREAM,
1259
+ typeof lastError?.message === 'string' && lastError.message.trim()
1260
+ ? lastError.message.trim()
1261
+ : 'Failed to load that coding session.',
1262
+ );
1263
+ }
1264
+ if (getResult.missing || isMissing(getResult)) {
1265
+ throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
1266
+ }
1267
+ if (getResult.error) {
1268
+ throw new AssignError(
1269
+ ASSIGN_CODES.UPSTREAM,
1270
+ typeof getResult.error?.message === 'string' && getResult.error.message.trim()
1271
+ ? getResult.error.message.trim()
1272
+ : 'Failed to load that coding session.',
1273
+ );
1274
+ }
1275
+ const sessionPayload = getResult.data ?? getResult;
1276
+ const metaDirectory = extractSessionDirectory(sessionPayload);
1277
+ const directoryRaw = metaDirectory || indexRow?.directory || usedDirectoryHint;
1278
+ if (!directoryRaw) {
1279
+ throw new AssignError(
1280
+ ASSIGN_CODES.VALIDATION,
1281
+ 'That session has no resolvable project directory.',
1282
+ );
1283
+ }
1284
+ // Registered project scope = allowed roots (same gate as assign create).
1285
+ // Reject managed assistant-workspaces and paths outside Settings projects.
1286
+ let directory;
1287
+ try {
1288
+ directory = resolveAssignDirectory({
1289
+ projectPath: directoryRaw,
1290
+ directory: directoryRaw,
1291
+ branch: null,
1292
+ allowedRoots: getAllowedRoots(),
1293
+ managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
1294
+ worktrees: [],
1295
+ });
1296
+ } catch (error) {
1297
+ if (error instanceof AssignError) throw error;
1298
+ throw new AssignError(
1299
+ ASSIGN_CODES.WORKSPACE_FORBIDDEN,
1300
+ 'That session is not under a registered project. Add the project in Settings first.',
1301
+ );
1302
+ }
1303
+
1304
+ let messagesResult = null;
1305
+ if (includeMessages && typeof client().session.messages === 'function') {
1306
+ try {
1307
+ messagesResult = await client().session.messages({
1308
+ sessionID,
1309
+ directory,
1310
+ limit: 100,
1311
+ }, options);
1312
+ if (messagesResult?.error && !isMissing(messagesResult)) {
1313
+ // Message fetch failure must not invent status; keep messages null.
1314
+ messagesResult = null;
1315
+ }
1316
+ } catch {
1317
+ messagesResult = null;
1318
+ }
1319
+ }
1320
+ const messageRows = Array.isArray(messagesResult?.data)
1321
+ ? messagesResult.data
1322
+ : (Array.isArray(messagesResult) ? messagesResult : null);
1323
+ const status = mapSessionToWatchStatus({
1324
+ session: sessionPayload,
1325
+ messages: messageRows,
1326
+ missing: false,
1327
+ });
1328
+ const title = (typeof titleHint === 'string' && titleHint.trim() ? titleHint.trim() : null)
1329
+ || extractSessionTitle(sessionPayload)
1330
+ || indexRow?.title
1331
+ || sessionID;
1332
+ return {
1333
+ sessionID,
1334
+ directory,
1335
+ title,
1336
+ status,
1337
+ session: sessionPayload,
1338
+ messages: messageRows,
1339
+ };
1340
+ };
1341
+ const readSessionWork = async (params = {}) => {
1342
+ const limit = params.limit === undefined ? 20 : params.limit;
1343
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new AssignError(ASSIGN_CODES.VALIDATION, 'limit must be an integer from 1 to 50.');
1344
+ if (params.before !== undefined && (typeof params.before !== 'string' || !params.before || params.before.length > 2048)) throw new AssignError(ASSIGN_CODES.VALIDATION, 'before must be an opaque cursor.');
1345
+ const { signal } = params;
1346
+ signal?.throwIfAborted();
1347
+ const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
1348
+ const result = await client().session.messages({
1349
+ sessionID: resolved.sessionID, directory: resolved.directory, limit,
1350
+ ...(params.before ? { before: params.before } : {}),
1351
+ }, signal ? { signal } : undefined);
1352
+ signal?.throwIfAborted();
1353
+ if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
1354
+ if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
1355
+ const rows = result.data;
1356
+ if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
1357
+ let remaining = 24000;
1358
+ let partial = rows.length > limit;
1359
+ const text = (value) => {
1360
+ if (typeof value !== 'string') return '';
1361
+ const kept = value.slice(0, Math.min(4000, remaining));
1362
+ remaining -= kept.length;
1363
+ if (kept.length < value.length) partial = true;
1364
+ return kept;
1365
+ };
1366
+ const messages = rows.slice(-limit).map((entry) => {
1367
+ const info = entry.info ?? entry;
1368
+ const parts = [];
1369
+ const sourceParts = Array.isArray(entry.parts) ? entry.parts : [];
1370
+ if (sourceParts.length > 20) partial = true;
1371
+ for (const part of sourceParts.slice(0, 20)) {
1372
+ if (part.type === 'text') parts.push({ type: 'text', text: text(part.text) });
1373
+ else if (part.type === 'file') { parts.push({ type: 'file', mime: part.mime, filename: text(part.filename) }); partial = true; }
1374
+ else if (part.type === 'tool') {
1375
+ parts.push({ type: 'tool', tool: text(part.tool), status: part.state?.status, output: text(part.state?.output), error: text(part.state?.error) });
1376
+ partial = true; // Inputs and binary details are intentionally omitted.
1377
+ } else if (part.type !== 'step-start' && part.type !== 'step-finish') partial = true;
1378
+ }
1379
+ return { messageID: info.id, role: info.role, parts };
1380
+ });
1381
+ const cursor = result.response?.headers?.get('x-next-cursor');
1382
+ const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
1383
+ return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
1384
+ };
1100
1385
  const assignWork = async (row, params = {}) => {
1101
1386
  const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
1102
1387
  const signal = params.signal;
@@ -1110,6 +1395,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1110
1395
  const reuseSessionID = typeof params.sessionID === 'string' && params.sessionID.trim()
1111
1396
  ? params.sessionID.trim()
1112
1397
  : '';
1398
+ // Reuse: resolve directory/project from authoritative session metadata first.
1399
+ // User projectPath/directory are hints only and never override scope.
1400
+ let reuseScope = null;
1401
+ if (reuseSessionID) {
1402
+ const directoryHint = typeof params.directory === 'string' && params.directory.trim()
1403
+ ? params.directory.trim()
1404
+ : (typeof params.projectPath === 'string' && params.projectPath.trim()
1405
+ ? params.projectPath.trim()
1406
+ : null);
1407
+ reuseScope = await resolveReferencedSession({
1408
+ sessionID: reuseSessionID,
1409
+ directoryHint,
1410
+ titleHint: typeof params.title === 'string' ? params.title : null,
1411
+ signal,
1412
+ // Messages only needed when following session model without explicit selection.
1413
+ includeMessages: !explicitModel,
1414
+ });
1415
+ }
1113
1416
  // Reused session without explicit model may follow the session's last model (catalog-gated).
1114
1417
  const followSessionModel = Boolean(reuseSessionID && !explicitModel);
1115
1418
  const needsCatalog = explicitModel || hasAssignImageParts(fileParts) || followSessionModel;
@@ -1127,17 +1430,18 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1127
1430
  catalog = await loadAssignCatalog(signal);
1128
1431
  }
1129
1432
  }
1130
- if (followSessionModel && catalog) {
1131
- const directoryHint = typeof params.directory === 'string' && params.directory.trim()
1132
- ? params.directory.trim()
1133
- : (typeof params.projectPath === 'string' && params.projectPath.trim()
1134
- ? params.projectPath.trim()
1135
- : null);
1136
- sessionModel = await lookupReusedSessionModel({
1137
- sessionID: reuseSessionID,
1138
- directory: directoryHint,
1139
- signal,
1433
+ if (followSessionModel && catalog && reuseScope) {
1434
+ sessionModel = extractAssignSessionModel({
1435
+ session: reuseScope.session,
1436
+ messages: reuseScope.messages,
1140
1437
  });
1438
+ if (!sessionModel) {
1439
+ sessionModel = await lookupReusedSessionModel({
1440
+ sessionID: reuseSessionID,
1441
+ directory: reuseScope.directory,
1442
+ signal,
1443
+ });
1444
+ }
1141
1445
  }
1142
1446
  const workerModel = resolveAssignWorkerModel({
1143
1447
  providerID: params.providerID,
@@ -1158,6 +1462,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1158
1462
  // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
1159
1463
  return assignSession({
1160
1464
  ...params,
1465
+ ...(reuseScope ? {
1466
+ sessionID: reuseScope.sessionID,
1467
+ directory: reuseScope.directory,
1468
+ projectPath: reuseScope.directory,
1469
+ // Drop untrusted branch when reusing — directory is already authoritative.
1470
+ branch: undefined,
1471
+ title: typeof params.title === 'string' && params.title.trim()
1472
+ ? params.title.trim()
1473
+ : reuseScope.title,
1474
+ } : {}),
1161
1475
  fileParts,
1162
1476
  ...(params.variant !== undefined ? { variant: params.variant } : {}),
1163
1477
  ...(signal ? { signal } : {}),
@@ -1184,6 +1498,112 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1184
1498
  messageID: params?.messageID || `msg_assign_${id()}`,
1185
1499
  });
1186
1500
  };
1501
+ /** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
1502
+ const watchSessionWork = async (_row, params = {}) => {
1503
+ const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1504
+ if (!sessionID) {
1505
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'watch_session requires sessionID.');
1506
+ }
1507
+ const resolved = await resolveReferencedSession({
1508
+ sessionID,
1509
+ // directory/projectPath from the model are never trusted as scope authority.
1510
+ directoryHint: null,
1511
+ titleHint: typeof params.title === 'string' ? params.title : null,
1512
+ signal: params.signal,
1513
+ includeMessages: true,
1514
+ });
1515
+ return {
1516
+ sessionID: resolved.sessionID,
1517
+ directory: resolved.directory,
1518
+ title: resolved.title,
1519
+ status: resolved.status,
1520
+ watched: true,
1521
+ reused: true,
1522
+ };
1523
+ };
1524
+ /** Abort an existing coding session via real OpenCode session.abort. */
1525
+ const stopSessionWork = async (_row, params = {}) => {
1526
+ const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1527
+ if (!sessionID) {
1528
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'stop_session requires sessionID.');
1529
+ }
1530
+ const resolved = await resolveReferencedSession({
1531
+ sessionID,
1532
+ directoryHint: null,
1533
+ signal: params.signal,
1534
+ // Abort does not need messages for baseline.
1535
+ includeMessages: false,
1536
+ });
1537
+ const signal = params.signal;
1538
+ signal?.throwIfAborted();
1539
+ const sdkOptions = signal ? { signal } : undefined;
1540
+ if (typeof client().session.abort !== 'function') {
1541
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
1542
+ }
1543
+ let result;
1544
+ try {
1545
+ result = await client().session.abort({
1546
+ sessionID: resolved.sessionID,
1547
+ directory: resolved.directory,
1548
+ }, sdkOptions);
1549
+ } catch (error) {
1550
+ throw new AssignError(
1551
+ ASSIGN_CODES.UPSTREAM,
1552
+ typeof error?.message === 'string' && error.message.trim()
1553
+ ? error.message.trim()
1554
+ : 'Failed to abort that coding session.',
1555
+ );
1556
+ }
1557
+ if (isMissing(result)) {
1558
+ throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${resolved.sessionID} was found.`);
1559
+ }
1560
+ if (result?.error || result?.data === false) {
1561
+ throw new AssignError(
1562
+ ASSIGN_CODES.UPSTREAM,
1563
+ typeof result.error?.message === 'string' && result.error.message.trim()
1564
+ ? result.error.message.trim()
1565
+ : 'Failed to abort that coding session.',
1566
+ );
1567
+ }
1568
+ reportAssignedSession(resolved.sessionID, 'cancelled');
1569
+ return {
1570
+ sessionID: resolved.sessionID,
1571
+ directory: resolved.directory,
1572
+ title: resolved.title,
1573
+ aborted: true,
1574
+ };
1575
+ };
1576
+ // Resolve scope from the authoritative session, never model-supplied directories.
1577
+ const mutateSessionWork = async (operation, params = {}) => {
1578
+ const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1579
+ if (!sessionID) throw new AssignError(ASSIGN_CODES.VALIDATION, `${operation} requires sessionID.`);
1580
+ if (operation === 'steer' && (typeof params.text !== 'string' || !params.text.trim())) {
1581
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'steer requires text.');
1582
+ }
1583
+ const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
1584
+ params.signal?.throwIfAborted();
1585
+ const session = client().session;
1586
+ const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
1587
+ if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
1588
+ const request = { sessionID, directory: resolved.directory };
1589
+ if (operation === 'steer') Object.assign(request, {
1590
+ messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
1591
+ });
1592
+ if (operation === 'archive') request.time = { archived: now() };
1593
+ let result;
1594
+ try {
1595
+ result = await session[method](request, params.signal ? { signal: params.signal } : undefined);
1596
+ } catch (error) {
1597
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
1598
+ }
1599
+ if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
1600
+ if (result?.error || result?.data === false || (operation === 'steer' && !promptAdmitted(result))) {
1601
+ // Do not retry an ambiguous prompt submission: it may already be admitted upstream.
1602
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
1603
+ }
1604
+ if (operation !== 'steer') reportAssignedSession(sessionID, 'cancelled');
1605
+ return { sessionID, directory: resolved.directory, operation, ...(operation === 'steer' ? { messageID: request.messageID, admitted: true } : { [operation === 'archive' ? 'archived' : 'deleted']: true }) };
1606
+ };
1187
1607
  const matchRegisteredProject = async (directory) => {
1188
1608
  const projects = await listProjects();
1189
1609
  const resolved = path.resolve(directory);
@@ -1467,7 +1887,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1467
1887
  lines.push(
1468
1888
  'Instructions:',
1469
1889
  '- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed".',
1470
- '- If more coding work is needed, call assign_session with the SAME sessionID and omit model args so the prior worker model is reused.',
1890
+ '- This is a read-only result notification, never authorization to create, restart, continue, steer, or otherwise mutate any session or file.',
1891
+ '- Respect the latest user instructions in history, including stop/cancel. If work remains, report it and wait for an explicit user request.',
1471
1892
  '- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
1472
1893
  );
1473
1894
  return lines.join('\n');
@@ -1489,6 +1910,15 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1489
1910
  || getContactMessage(db, `${messageID}:error`)
1490
1911
  ) return;
1491
1912
 
1913
+ if (assignedResumes.has(messageID)) return;
1914
+ const controller = new AbortController();
1915
+ assignedResumes.set(messageID, { assistantID, sessionID, controller });
1916
+ const finishCancelled = () => {
1917
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1918
+ emitContactTurnEvent('openchamber:contact-turn-end', { assistantID, turnID, status: 'cancelled' });
1919
+ resolveContactTurnSettlement(messageID, { status: 'cancelled' });
1920
+ };
1921
+
1492
1922
  rememberActiveContactTurn(assistantID, {
1493
1923
  turnID,
1494
1924
  messageID,
@@ -1505,6 +1935,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1505
1935
  contactTurnSettlement(messageID);
1506
1936
 
1507
1937
  void inContactTurnLane(assistantID, async () => {
1938
+ if (controller.signal.aborted) { finishCancelled(); return; }
1508
1939
  if (closed) {
1509
1940
  settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1510
1941
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1532,13 +1963,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1532
1963
  } catch {
1533
1964
  registeredProjects = [];
1534
1965
  }
1535
- let connectedModels = [];
1536
- try {
1537
- const catalog = await loadAssignCatalog(undefined);
1538
- connectedModels = normalizeConnectedModels(catalog?.models);
1539
- } catch {
1540
- connectedModels = [];
1541
- }
1966
+ const modelContext = await loadContactModelContext(controller.signal);
1542
1967
 
1543
1968
  let workerText = '';
1544
1969
  try {
@@ -1560,6 +1985,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1560
1985
  const rows = Array.isArray(messagesResult?.data)
1561
1986
  ? messagesResult.data
1562
1987
  : (Array.isArray(messagesResult) ? messagesResult : []);
1988
+ if (isUserAbort(lastAssistantInfo(rows)?.error)) {
1989
+ reportAssignedSession(sessionID, 'cancelled');
1990
+ finishCancelled();
1991
+ return;
1992
+ }
1563
1993
  workerText = extractLastWorkerAssistantText(rows);
1564
1994
  }
1565
1995
  }
@@ -1567,6 +1997,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1567
1997
  workerText = '';
1568
1998
  }
1569
1999
 
2000
+ if (controller.signal.aborted) { finishCancelled(); return; }
1570
2001
  const title = findAssignedSessionCardTitle(assistantID, sessionID);
1571
2002
  const userText = buildAssignedSessionResumeUserText({
1572
2003
  sessionID,
@@ -1589,6 +2020,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1589
2020
  );
1590
2021
  const tools = createContactTools({
1591
2022
  assignWork: (params) => assignWork(live, params),
2023
+ watchSession: (params) => watchSessionWork(live, params),
2024
+ stopSession: (params) => stopSessionWork(live, params),
2025
+ steerSession: (params) => mutateSessionWork('steer', params),
2026
+ archiveSession: (params) => mutateSessionWork('archive', params),
2027
+ deleteSession: (params) => mutateSessionWork('delete', params),
1592
2028
  createAssistant: (toolInput) => createAssistant(toolInput),
1593
2029
  scheduleTask: (params) => scheduleWork(live, params),
1594
2030
  deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
@@ -1606,6 +2042,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1606
2042
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1607
2043
  listProjects: async () => loadRegisteredProjects(),
1608
2044
  listSessions: (params) => listSessionsWork(params),
2045
+ readSession: (params) => readSessionWork(params),
1609
2046
  readAssistantSettings: (input = {}) => {
1610
2047
  const targetID = typeof input?.assistantID === 'string' && input.assistantID.trim()
1611
2048
  ? input.assistantID.trim()
@@ -1659,17 +2096,22 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1659
2096
  turnFileParts: [],
1660
2097
  turnAttachmentScope: attachmentScopeKey([]),
1661
2098
  });
2099
+ controller.signal.throwIfAborted();
1662
2100
  const generated = await runContactTurn({
2101
+ signal: controller.signal,
2102
+ readOnly: true,
1663
2103
  assistant: assistantSnapshot,
1664
2104
  history: executionHistory,
1665
2105
  userText,
1666
2106
  userParts: [{ type: 'text', text: userText }],
2107
+ language: contactTurnLanguages.get(assistantID) ?? '',
1667
2108
  createChatCompletion,
1668
2109
  tools,
1669
2110
  projects: registeredProjects,
1670
- connectedModels,
2111
+ ...modelContext,
1671
2112
  globalEventHub,
1672
2113
  onBubbleDelta: (bubbleIndex, delta, done) => {
2114
+ if (controller.signal.aborted) return;
1673
2115
  emitContactTurnEvent('openchamber:contact-bubble-delta', {
1674
2116
  assistantID,
1675
2117
  turnID,
@@ -1679,6 +2121,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1679
2121
  });
1680
2122
  },
1681
2123
  });
2124
+ if (controller.signal.aborted) { finishCancelled(); return; }
1682
2125
  if (closed) {
1683
2126
  settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1684
2127
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1751,6 +2194,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1751
2194
  });
1752
2195
  resolveContactTurnSettlement(messageID, { status: 'complete' });
1753
2196
  } catch (error) {
2197
+ if (controller.signal.aborted) { finishCancelled(); return; }
1754
2198
  const detail = typeof error?.message === 'string' && error.message.trim()
1755
2199
  ? error.message.trim()
1756
2200
  : (error?.code || 'upstream_error');
@@ -1781,7 +2225,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1781
2225
  });
1782
2226
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
1783
2227
  }
1784
- });
2228
+ }).finally(() => { assignedResumes.delete(messageID); });
1785
2229
  };
1786
2230
  assignedSessionResumeRef.schedule = scheduleAssignedSessionResume;
1787
2231
  /**
@@ -1795,7 +2239,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1795
2239
  const messageID = string(input.messageID, 256, true);
1796
2240
  const userText = extractUserText(input);
1797
2241
  if (!userText) fail('validation_error');
2242
+ const language = typeof input.language === 'string' ? input.language.trim().slice(0, 64) : '';
1798
2243
  const row = active(assistantID);
2244
+ if (language) contactTurnLanguages.set(row.assistant_id, language);
1799
2245
  const userParts = await userContactParts(row.assistant_id, input, userText);
1800
2246
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1801
2247
  let registeredProjects = [];
@@ -1825,12 +2271,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1825
2271
  replayed: true,
1826
2272
  };
1827
2273
  }
2274
+ cancelAssignedResumes({ assistantID: row.assistant_id });
2275
+ // A new user turn supersedes pending background notifications as well as running ones.
2276
+ // Explicit assign/watch admission rearms only its own watch. Keep live card status intact.
2277
+ db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(row.assistant_id);
1828
2278
  admitContactUser(row.assistant_id, {
1829
2279
  userMessageID: messageID,
1830
2280
  userText,
1831
2281
  userParts,
1832
2282
  turnID,
1833
2283
  });
2284
+ const controller = new AbortController();
2285
+ contactControllers.set(messageID, { assistantID: row.assistant_id, controller });
2286
+ const finishCancelled = () => {
2287
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
2288
+ emitContactTurnEvent('openchamber:contact-turn-end', { assistantID: row.assistant_id, turnID, status: 'cancelled' });
2289
+ resolveContactTurnSettlement(messageID, { status: 'cancelled' });
2290
+ contactControllers.delete(messageID);
2291
+ };
1834
2292
  rememberActiveContactTurn(row.assistant_id, {
1835
2293
  turnID,
1836
2294
  messageID,
@@ -1845,6 +2303,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1845
2303
  contactTurnSettlement(messageID);
1846
2304
  const kickTurn = () => {
1847
2305
  void inContactTurnLane(row.assistant_id, async () => {
2306
+ if (controller.signal.aborted) { finishCancelled(); return; }
1848
2307
  if (closed) {
1849
2308
  settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1850
2309
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1867,15 +2326,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1867
2326
  const assignedCards = [];
1868
2327
  let contactResetThisTurn = false;
1869
2328
  let contactHistoryClearedThisTurn = false;
1870
- let connectedModels = [];
1871
- try {
1872
- const catalog = await loadAssignCatalog(undefined);
1873
- connectedModels = normalizeConnectedModels(catalog?.models);
1874
- } catch {
1875
- // Catalog failure must not block default-model contact turns.
1876
- // Explicit worker model / image capability checks still fail closed inside assignWork.
1877
- connectedModels = [];
1878
- }
2329
+ const modelContext = await loadContactModelContext(controller.signal);
1879
2330
  try {
1880
2331
  // Materialize inside turn try so failures durable-error + settle working.
1881
2332
  // DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
@@ -1900,6 +2351,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1900
2351
  const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1901
2352
  const tools = createContactTools({
1902
2353
  assignWork: (params) => assignWork(row, params),
2354
+ watchSession: (params) => watchSessionWork(row, params),
2355
+ stopSession: (params) => stopSessionWork(row, params),
2356
+ steerSession: (params) => mutateSessionWork('steer', params),
2357
+ archiveSession: (params) => mutateSessionWork('archive', params),
2358
+ deleteSession: (params) => mutateSessionWork('delete', params),
1903
2359
  createAssistant: (toolInput) => createAssistant(toolInput),
1904
2360
  scheduleTask: (params) => scheduleWork(row, params),
1905
2361
  deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
@@ -1925,6 +2381,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1925
2381
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1926
2382
  listProjects: async () => loadRegisteredProjects(),
1927
2383
  listSessions: (params) => listSessionsWork(params),
2384
+ readSession: (params) => readSessionWork(params),
1928
2385
  // Live DB read — not the turn-start assistantSnapshot.
1929
2386
  // Optional assistantID targets another live row; omitted → this contact.
1930
2387
  readAssistantSettings: (input = {}) => {
@@ -1982,17 +2439,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1982
2439
  turnFileParts,
1983
2440
  turnAttachmentScope,
1984
2441
  });
2442
+ controller.signal.throwIfAborted();
1985
2443
  const generated = await runContactTurn({
2444
+ signal: controller.signal,
1986
2445
  assistant: assistantSnapshot,
1987
2446
  history: executionHistory,
1988
2447
  userText,
1989
2448
  userParts: executionParts,
2449
+ language,
1990
2450
  createChatCompletion,
1991
2451
  tools,
1992
2452
  projects: registeredProjects,
1993
- connectedModels,
2453
+ ...modelContext,
1994
2454
  globalEventHub,
1995
2455
  onBubbleDelta: (bubbleIndex, delta, done) => {
2456
+ if (controller.signal.aborted) return;
1996
2457
  emitContactTurnEvent('openchamber:contact-bubble-delta', {
1997
2458
  assistantID: row.assistant_id,
1998
2459
  turnID,
@@ -2002,6 +2463,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2002
2463
  });
2003
2464
  },
2004
2465
  });
2466
+ if (controller.signal.aborted) { finishCancelled(); return; }
2005
2467
  if (closed) {
2006
2468
  settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
2007
2469
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -2088,6 +2550,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2088
2550
  });
2089
2551
  resolveContactTurnSettlement(messageID, { status: 'complete' });
2090
2552
  } catch (error) {
2553
+ if (controller.signal.aborted) { finishCancelled(); return; }
2091
2554
  const detail = typeof error?.message === 'string' && error.message.trim()
2092
2555
  ? error.message.trim()
2093
2556
  : (error?.code || 'upstream_error');
@@ -2117,6 +2580,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2117
2580
  body: detail,
2118
2581
  });
2119
2582
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
2583
+ } finally {
2584
+ contactControllers.delete(messageID);
2120
2585
  }
2121
2586
  });
2122
2587
  };
@@ -2196,6 +2661,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2196
2661
  deleteContactMessages(db, row.assistant_id, {
2197
2662
  ...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
2198
2663
  });
2664
+ // Rebind read watermark to the new generation at empty cursor so post-wipe
2665
+ // confirm bubbles can surface as unread; stale generation mark-read fails closed.
2666
+ resetContactReadWatermarkForGeneration(db, row.assistant_id, generation, { updatedAt: now() });
2199
2667
  // Active contact turns stay until settle — wipe must not drop the green
2200
2668
  // dot while the clearing turn is still running.
2201
2669
  bump();
@@ -2206,6 +2674,48 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2206
2674
  throw error;
2207
2675
  }
2208
2676
  };
2677
+ /**
2678
+ * Advance the shared contact read watermark (multi-client).
2679
+ * Body: { generation, ordinal, messageID } — must match live generation;
2680
+ * only monotonic forward moves bump revision / assistants-changed.
2681
+ * Idempotent no-op when unchanged (no broadcast).
2682
+ */
2683
+ const markContactRead = (assistantID, input = {}) => {
2684
+ const row = editable(assistantID);
2685
+ if (!plainObject(input)) fail('validation_error');
2686
+ db.exec('BEGIN IMMEDIATE');
2687
+ try {
2688
+ let advanced;
2689
+ try {
2690
+ advanced = advanceContactReadWatermark(db, row.assistant_id, {
2691
+ generation: input.generation,
2692
+ ordinal: input.ordinal,
2693
+ messageID: input.messageID,
2694
+ }, { updatedAt: now() });
2695
+ } catch (error) {
2696
+ if (error?.code === 'contact_generation_conflict' || error?.code === 'validation_error') {
2697
+ fail(error.code, error.message);
2698
+ }
2699
+ throw error;
2700
+ }
2701
+ let tipRevision = revision();
2702
+ if (advanced.changed) {
2703
+ tipRevision = bump();
2704
+ }
2705
+ db.exec('COMMIT');
2706
+ return {
2707
+ assistantID: row.assistant_id,
2708
+ changed: advanced.changed,
2709
+ unreadCount: advanced.unreadCount,
2710
+ readWatermark: advanced.readWatermark,
2711
+ readTip: advanced.readTip,
2712
+ revision: tipRevision,
2713
+ };
2714
+ } catch (error) {
2715
+ db.exec('ROLLBACK');
2716
+ throw error;
2717
+ }
2718
+ };
2209
2719
  const appendContactCard = (assistantID, input) => {
2210
2720
  const row = active(assistantID);
2211
2721
  const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
@@ -2317,7 +2827,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2317
2827
  };
2318
2828
  return mode === 'stateless' ? inStatelessLane(deliveryTarget.assistantID, submit) : submit();
2319
2829
  };
2320
- const abort = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) }); if (isMissing(result)) fail('not_found'); if (result.error) fail('upstream_error'); return { binding: binding(row), aborted: true }; };
2830
+ const abort = async (assistantID, input) => {
2831
+ const row = active(assistantID);
2832
+ if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation) fail('revision_conflict');
2833
+ let interrupted = false;
2834
+ for (const turn of contactControllers.values()) {
2835
+ if (turn.assistantID !== assistantID) continue;
2836
+ turn.controller.abort();
2837
+ interrupted = true;
2838
+ }
2839
+ for (const turn of assignedResumes.values()) {
2840
+ if (turn.assistantID !== assistantID) continue;
2841
+ turn.controller.abort();
2842
+ interrupted = true;
2843
+ }
2844
+ db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
2845
+ if (interrupted) return { binding: binding(row), aborted: true };
2846
+ if (!row.current_session_id) fail('revision_conflict');
2847
+ const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) });
2848
+ if (isMissing(result)) fail('not_found');
2849
+ if (result.error) fail('upstream_error');
2850
+ return { binding: binding(row), aborted: true };
2851
+ };
2321
2852
  const createNew = async (assistantID) => { for (let attempt = 0; attempt < 3; attempt++) { const row = active(assistantID); const created = await createSession(row); const won = replaceBinding(row, created); if (won) return binding(won); } return binding(active(assistantID)); };
2322
2853
  const shareOperation = (operationID) => { const row = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); return row && { operationID: row.operation_id, assistantID: row.assistant_id, sessionID: row.session_id, messageID: row.message_id, state: row.state, phase: row.phase, attempt: row.attempt, leaseExpiresAt: row.lease_expires_at, errorCode: row.error_code }; };
2323
2854
  const claim = (operationID, retry = false) => { db.exec('BEGIN IMMEDIATE'); try { const operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (!operation) { db.exec('COMMIT'); return null; } const at = now(); const eligible = operation.state === 'failed' ? retry : operation.state === 'running' && operation.lease_expires_at <= at && operation.phase === 'admitted'; if (!eligible || operation.attempt >= SHARE_MAX_ATTEMPTS) { db.exec('COMMIT'); return null; } const result = db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitting',attempt=attempt+1,lease_expires_at=?,error_code=NULL,updated_at=? WHERE operation_id=? AND state=? AND phase=? AND attempt<? AND (state='failed' OR lease_expires_at<=?)").run(at + SHARE_LEASE_MS, at, operationID, operation.state, operation.phase, SHARE_MAX_ATTEMPTS, at); const claimed = result.changes ? db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID) : null; db.exec('COMMIT'); return claimed; } catch (error) { db.exec('ROLLBACK'); throw error; } };
@@ -2384,12 +2915,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2384
2915
  });
2385
2916
  const snapshot = () => {
2386
2917
  const rows = db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all();
2918
+ const assistantIDs = rows.map((row) => row.assistant_id);
2387
2919
  // Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
2388
- const previews = getLatestContactMessagePreviews(db, rows.map((row) => row.assistant_id));
2920
+ const previews = getLatestContactMessagePreviews(db, assistantIDs);
2921
+ // Batch unread/watermark/tip — no per-assistant N+1 on snapshot.
2922
+ const unreadByID = getContactUnreadSnapshots(db, assistantIDs);
2389
2923
  return {
2390
2924
  revision: revision(),
2391
2925
  enabled: enabled(),
2392
- assistants: rows.map((row) => output(row, previews.get(row.assistant_id) ?? null)),
2926
+ assistants: rows.map((row) => output(
2927
+ row,
2928
+ previews.get(row.assistant_id) ?? null,
2929
+ unreadByID.get(row.assistant_id) ?? null,
2930
+ )),
2393
2931
  };
2394
2932
  };
2395
2933
  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) => {
@@ -2403,7 +2941,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2403
2941
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
2404
2942
  db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
2405
2943
  deleteContactMessages(db, assistantID);
2944
+ deleteContactReadState(db, assistantID);
2406
2945
  clearActiveContactTurns(assistantID);
2946
+ contactTurnLanguages.delete(assistantID);
2407
2947
  bump();
2408
2948
  db.exec('COMMIT');
2409
2949
  return { assistantID, tombstoneAt: now() };
@@ -2411,11 +2951,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2411
2951
  db.exec('ROLLBACK');
2412
2952
  throw error;
2413
2953
  }
2414
- }, 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: () => {
2954
+ }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, markContactRead, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
2955
+ cancelAssignedResumes({});
2956
+ for (const turn of contactControllers.values()) turn.controller.abort();
2415
2957
  if (!closed) {
2416
2958
  closed = true;
2417
2959
  try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
2418
2960
  clearActiveContactTurns();
2961
+ contactTurnLanguages.clear();
2419
2962
  unsubscribeEvents?.();
2420
2963
  clearIntervalFn(timer);
2421
2964
  db.close();