@openchambery/web 1.19.13 → 1.19.15-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 (120) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-m1jNq4bX.js} +1 -1
  2. package/dist/assets/AssistantView-D5rEZ-Ss.js +2 -0
  3. package/dist/assets/{DiagramView-BdI4PT2H.js → DiagramView-DaB9Sb7S.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-emLeoNjI.js +2 -0
  7. package/dist/assets/{MarkdownRendererImpl-2ewbRZE2.js → MarkdownRendererImpl-BWCAPlqn.js} +3 -3
  8. package/dist/assets/{MarkstreamRendererImpl-DXewO7S_.js → MarkstreamRendererImpl-BD_NFKUv.js} +2 -2
  9. package/dist/assets/MobileDetailNavigation-DqXVAIap.js +1 -0
  10. package/dist/assets/MobileShareBridge-Bk2fdkJk.js +4 -0
  11. package/dist/assets/MobileSurface-C4mpcOCh.js +2 -0
  12. package/dist/assets/{MultiRunWindow-C8ukIdLA.js → MultiRunWindow-rs49_K1E.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-b8LUjoad.js +25 -0
  16. package/dist/assets/{SettingsWindow-CSePZ_V8.js → SettingsWindow-D8JFlPdA.js} +1 -1
  17. package/dist/assets/{TerminalView-BmSIrZ81.js → TerminalView-DCd-Godi.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-2BYaWLo1.js → ToolOutputDialog-B50L_nUP.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-Dk4gBaOu.js → appThemeRegistry-BWtCaTMe.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-DKYkoypI.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-D4PyYgLZ.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-Bnnx03Wj.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-BRiQTNFX.js +169 -0
  38. package/dist/assets/{main-Ci3JmtpW.js → main-CnY0LWNc.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-B0pw_mUs.js +2 -0
  41. package/dist/assets/mobile-BKHmxwXz.js +2 -0
  42. package/dist/assets/{multirun-CmaTeZON.js → multirun-BQKqVfFT.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-BvG8sdtC.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-B1uHpjFW.js} +2 -2
  48. package/dist/assets/renderElectronMiniChatApp-E5a0yy7E.js +2 -0
  49. package/dist/assets/renderMobileApp-DT0L4Ah6.js +28 -0
  50. package/dist/assets/{runtimeConfig-DI8l0VyZ.js → runtimeConfig-utWzrDwa.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-DE6lwHyg.js +147 -0
  52. package/dist/assets/sessionChangeSummary-BG7l2heP.js +2 -0
  53. package/dist/assets/sessionLookup-12zVOR0J.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-dYW6SH62.js} +1 -1
  55. package/dist/assets/src-CFUi4riW.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-BhYuP4L2.js} +43 -44
  63. package/dist/assets/useDesktopWindowControlsLayout-DPNMRkrI.js +1 -0
  64. package/dist/assets/{useEffectiveDirectory-e9HjIqGz.js → useEffectiveDirectory-Ji8AKgzY.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-BYdpuwL7.js +413 -0
  67. package/dist/assets/useSessionAutoCleanup-CADZCvZL.js +2 -0
  68. package/dist/assets/{useWorkerHighlightedLines-DTGE0CDs.js → useWorkerHighlightedLines-CuG88Lsj.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 +120 -22
  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 +520 -11
  83. package/server/lib/assistants/contact-store.test.js +362 -12
  84. package/server/lib/assistants/contact-tools.js +305 -54
  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 +295 -132
  88. package/server/lib/assistants/harness.test.js +662 -10
  89. package/server/lib/assistants/pi-tools.js +2 -0
  90. package/server/lib/assistants/pi-tools.test.js +8 -0
  91. package/server/lib/assistants/routes.js +3 -0
  92. package/server/lib/assistants/service.js +861 -79
  93. package/server/lib/assistants/service.test.js +1234 -8
  94. package/server/lib/llm/DOCUMENTATION.md +10 -3
  95. package/server/lib/llm/completions.js +4 -0
  96. package/server/lib/llm/completions.test.js +3 -0
  97. package/server/lib/llm/generate.js +57 -11
  98. package/server/lib/llm/generate.test.js +92 -0
  99. package/server/lib/opencode/feature-routes-runtime.js +5 -0
  100. package/dist/assets/AssistantView-CKmIS1cS.js +0 -2
  101. package/dist/assets/Icon-CkzzhaLr.js +0 -1
  102. package/dist/assets/MarkdownRenderer-CLQ4Z8Z0.js +0 -2
  103. package/dist/assets/MobileDetailNavigation-BGaAyuBH.js +0 -1
  104. package/dist/assets/MobileShareBridge-BAZoyeIP.js +0 -4
  105. package/dist/assets/MobileSurface-BVb3xNWt.js +0 -2
  106. package/dist/assets/SettingsView-CkQ61pbr.js +0 -25
  107. package/dist/assets/insertionBoundaries-BMhzPFft.js +0 -4
  108. package/dist/assets/main-BjeOhdcG.js +0 -169
  109. package/dist/assets/miniChat-BYrjYEXJ.js +0 -2
  110. package/dist/assets/mobile-BVMNnPK2.js +0 -2
  111. package/dist/assets/platform-DzsGqlGW.js +0 -2
  112. package/dist/assets/renderElectronMiniChatApp-COwia8Cs.js +0 -2
  113. package/dist/assets/renderMobileApp-CXwwpY1t.js +0 -28
  114. package/dist/assets/runtimeEndpointReset-BkAQXmXJ.js +0 -147
  115. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  116. package/dist/assets/sessionLookup-Imc995rW.js +0 -1
  117. package/dist/assets/src-AA-5nXsF.css +0 -2
  118. package/dist/assets/useDesktopWindowControlsLayout-C5hZD1co.js +0 -1
  119. package/dist/assets/useMobileNavigationStore-CYbDxRTM.js +0 -413
  120. package/dist/assets/useSessionAutoCleanup-BGy9_AhN.js +0 -2
@@ -10,22 +10,30 @@ 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,
20
+ deleteContactTurnAssistantOutputs,
18
21
  ensureContactSchema,
19
22
  contactPartsFingerprint,
20
23
  getContactMessage,
24
+ getContactReadTip,
25
+ getContactReadWatermark,
26
+ getContactUnreadSnapshots,
21
27
  getLatestContactMessagePreview,
22
28
  getLatestContactMessagePreviews,
23
29
  insertContactMessage,
24
30
  listContactMessages,
25
31
  listInFlightWatches,
26
32
  listWatchesBySession,
33
+ migrateContactReadStateDefaultRead,
27
34
  nextContactOrdinal,
28
35
  projectActiveContactTurn,
36
+ resetContactReadWatermarkForGeneration,
29
37
  updateSessionCardStatus,
30
38
  upsertContactWatch,
31
39
  } from './contact-store.js';
@@ -63,7 +71,10 @@ import {
63
71
  ASSIGN_CODES,
64
72
  assignSession,
65
73
  attachmentScopeKey,
74
+ extractSessionDirectory,
75
+ extractSessionTitle,
66
76
  hasAssignImageParts,
77
+ mapSessionToWatchStatus,
67
78
  resolveAssignDirectory,
68
79
  extractAssignSessionModel,
69
80
  resolveAssignWorkerModel,
@@ -73,7 +84,7 @@ import { runContactTurn as defaultRunContactTurn } from './harness.js';
73
84
  import { loadConnectedCatalog } from '../llm/catalog.js';
74
85
 
75
86
  const require = createRequire(import.meta.url);
76
- const SCHEMA_VERSION = 12;
87
+ const SCHEMA_VERSION = 13;
77
88
  const normalizeSessionDirectory = (value) => {
78
89
  if (typeof value !== 'string') return null;
79
90
  const trimmed = value.trim();
@@ -130,10 +141,11 @@ const promptAdmitted = (result) => !result?.error && (result?.response?.status =
130
141
  export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
131
142
  /** Bound last worker assistant text injected into assigned-session resume prompts. */
132
143
  export const ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX = 2_000;
133
- /** Stable resume turn/message id for assigned-session complete/error continuation. */
144
+ /** Stable resume turn/message id for assigned-session complete/error/cancelled continuation. */
134
145
  export const assignedSessionResumeMessageID = (assistantID, sessionID, status, updatedAt) => (
135
146
  `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`
136
147
  );
148
+ const assignedSessionResumeStatus = (status) => status === 'complete' || status === 'error' || status === 'cancelled';
137
149
  const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
138
150
  let timer = null;
139
151
  try {
@@ -152,7 +164,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
152
164
  }
153
165
  };
154
166
 
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 } = {}) => {
167
+ 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
168
  if (!dbPath || !dataDir) return null;
157
169
  const Database = require('better-sqlite3');
158
170
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -289,12 +301,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
289
301
  }
290
302
  activeContactTurnsByAssistant.delete(assistantID);
291
303
  };
292
- const output = (row, latestMessagePreview) => {
304
+ const output = (row, latestMessagePreview, unreadSnapshot) => {
293
305
  const watches = listInFlightWatches(db, row.assistant_id);
294
306
  const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
295
307
  const preview = latestMessagePreview !== undefined
296
308
  ? latestMessagePreview
297
309
  : getLatestContactMessagePreview(db, row.assistant_id);
310
+ const unread = unreadSnapshot !== undefined && unreadSnapshot !== null
311
+ ? unreadSnapshot
312
+ : (getContactUnreadSnapshots(db, [row.assistant_id]).get(row.assistant_id) ?? {
313
+ unreadCount: 0,
314
+ readWatermark: getContactReadWatermark(db, row.assistant_id),
315
+ readTip: getContactReadTip(db, row.assistant_id),
316
+ });
298
317
  return {
299
318
  id: row.assistant_id,
300
319
  revision: row.revision,
@@ -321,6 +340,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
321
340
  // Contact-transcript list desc: authoritative last visible bubble (not
322
341
  // defaultPrompt / updatedAt). null when the contact has no list-visible rows.
323
342
  latestMessagePreview: preview ?? null,
343
+ // Shared multi-client unread: countable assistant/peer replies after watermark.
344
+ unreadCount: Number(unread.unreadCount) || 0,
345
+ // Persisted shared watermark (generation + keyset). Clients POST this tip safely.
346
+ readWatermark: unread.readWatermark,
347
+ // Highest readable tip for safe mark-read report (transcript head + generation).
348
+ readTip: unread.readTip,
324
349
  createdAt: row.created_at,
325
350
  updatedAt: row.updated_at,
326
351
  tombstoneAt: row.tombstone_at,
@@ -386,6 +411,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
386
411
  const legacy = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='assistant'").get();
387
412
  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
413
  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); }
414
+ // v13: shared contact read watermark. Legacy transcripts default to fully read
415
+ // so existing installs do not flash unread on upgrade; later messages count.
416
+ migrateContactReadStateDefaultRead(db, { updatedAt: now() });
389
417
  db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
390
418
  };
391
419
  migrate();
@@ -460,8 +488,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
460
488
  const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
461
489
  return nonEmptyString(sessionID) ? sessionID : '';
462
490
  };
491
+ const isUserAbort = (error) => error?.name === 'MessageAbortedError';
492
+ const assignedResumes = new Map();
493
+ const contactControllers = new Map();
494
+ // Last UI locale seen from a client per assistant. Background notifications
495
+ // (assigned-session settle) have no request locale, so they recall this value.
496
+ const contactTurnLanguages = new Map();
497
+ const cancelAssignedResumes = ({ sessionID, assistantID, statuses } = {}) => {
498
+ for (const resume of assignedResumes.values()) {
499
+ if (sessionID && resume.sessionID !== sessionID) continue;
500
+ if (assistantID && resume.assistantID !== assistantID) continue;
501
+ if (statuses && !statuses.includes(resume.status)) continue;
502
+ resume.controller.abort();
503
+ }
504
+ };
505
+ // Abort-request lifecycle only (stop_session). Concurrent same-session aborts share one promise.
506
+ // Observed SSE terminals are stashed until abort settles so resume=true cannot beat resume=false.
507
+ const sessionAbortInflight = new Map();
508
+ const abortTerminalRank = (status) => {
509
+ if (status === 'cancelled') return 3;
510
+ if (status === 'error') return 2;
511
+ if (status === 'complete') return 1;
512
+ return 0;
513
+ };
514
+ const stashSessionAbortTerminal = (entry, status, resume) => {
515
+ if (!entry || typeof status !== 'string' || !status) return;
516
+ const nextRank = abortTerminalRank(status);
517
+ if (nextRank <= 0) return;
518
+ const prev = entry.observed;
519
+ const prevRank = prev ? abortTerminalRank(prev.status) : -1;
520
+ // cancelled wins over complete/error; error wins over complete; equal rank keeps latest.
521
+ if (nextRank > prevRank || (nextRank === prevRank && (status === 'cancelled' || nextRank > 0))) {
522
+ entry.observed = { status, resume: resume !== false };
523
+ if (status === 'cancelled') entry.observed.resume = resume !== false;
524
+ }
525
+ };
463
526
  const settleStatusFromEvent = (payload, properties) => {
464
- if (payload.type === 'session.error') return 'error';
527
+ if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
465
528
  if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
466
529
  if (payload.type === 'session.idle') return 'complete';
467
530
  if (payload.type === 'session.status') {
@@ -471,10 +534,20 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
471
534
  }
472
535
  return null;
473
536
  };
474
- // Assigned-session complete/error resumes the contact LLM (no canned settle bubble).
537
+ // Assigned-session complete/error/cancelled resumes the contact LLM (no canned settle bubble).
475
538
  // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
476
539
  const assignedSessionResumeRef = { schedule: null };
477
- const reportAssignedSession = (sessionID, status) => {
540
+ const reportAssignedSession = (sessionID, status, resume = true) => {
541
+ const inflight = sessionAbortInflight.get(sessionID);
542
+ if (inflight) {
543
+ // Defer watch terminal + continuation until stop_session abort settles.
544
+ stashSessionAbortTerminal(inflight, status, resume);
545
+ return false;
546
+ }
547
+ // Drop complete/error notifies for this worker. Do not abort a cancelled
548
+ // interrupt notify — duplicate cancelled reports (boot+timer, error+message.updated)
549
+ // must still reach the contact LLM.
550
+ if (status === 'cancelled') cancelAssignedResumes({ sessionID, statuses: ['complete', 'error'] });
478
551
  const watches = listWatchesBySession(db, sessionID);
479
552
  if (watches.length === 0) return false;
480
553
  let changed = false;
@@ -482,7 +555,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
482
555
  db.exec('BEGIN IMMEDIATE');
483
556
  try {
484
557
  for (const watch of watches) {
485
- if (watch.status === status) continue;
558
+ if (watch.status === status || watch.status === 'cancelled') continue;
486
559
  // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
487
560
  if (status === 'complete' && watch.status === 'error') continue;
488
561
  const updatedAt = now();
@@ -493,10 +566,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
493
566
  directory: watch.directory,
494
567
  status,
495
568
  updatedAt,
569
+ resumeAllowed: watch.resumeAllowed,
496
570
  });
497
- // complete/error: hand result back to contact LLM. question: card only (worker waiting).
571
+ // complete/error/user-interrupt cancelled: hand result back to contact LLM.
572
+ // question: card only (worker waiting). stop_session passes resume=false.
498
573
  // Never insert oc.settle.* canned transcript bubbles.
499
- if (status === 'complete' || status === 'error') {
574
+ if (resume && watch.resumeAllowed && assignedSessionResumeStatus(status)) {
500
575
  resumes.push({
501
576
  assistantID: watch.assistantID,
502
577
  sessionID,
@@ -537,12 +612,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
537
612
  if (isMissing(getResult)) return 'complete';
538
613
  if (getResult?.error) return null;
539
614
  const session = getResult?.data;
540
- if (session?.error) return 'error';
615
+ if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
541
616
  const statusType = sessionStatusType(session);
542
617
  if (statusType === 'busy' || statusType === 'retry') return null;
543
618
  if (isMissing(messagesResult)) return 'complete';
544
619
  const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
545
- if (assistant?.error) return 'error';
620
+ if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
546
621
  if (statusType === 'idle' || session?.time?.completed) return 'complete';
547
622
  if (assistant?.time?.completed) return 'complete';
548
623
  return null;
@@ -596,7 +671,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
596
671
  if (payload.type === 'message.updated') {
597
672
  const info = properties.info; const sessionID = info?.sessionID;
598
673
  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;
674
+ const cancelled = info.role === 'assistant' && isUserAbort(info.error)
675
+ ? reportAssignedSession(sessionID, 'cancelled') : false;
676
+ 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
677
  }
601
678
  if (payload.type === 'message.part.updated') {
602
679
  const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
@@ -835,7 +912,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
835
912
  if (parts.length === 0 && userText) parts.push({ type: 'text', text: userText });
836
913
  return parts;
837
914
  };
838
- /** Admit the user row only — HTTP 202 returns here; assistant bubbles persist later. */
915
+ /** Admit the user row only — HTTP 202 returns here; assistant bubbles persist as they publish. */
839
916
  const admitContactUser = (assistantID, { userMessageID, userText, userParts, turnID }) => {
840
917
  db.exec('BEGIN IMMEDIATE');
841
918
  try {
@@ -882,13 +959,135 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
882
959
  throw writeError;
883
960
  }
884
961
  };
885
- /** Persist assistant bubbles/cards for an already-admitted user turn (no user row). */
886
- const persistContactAssistantReply = (assistantID, { userMessageID, bubbles, cards = [], turnID }) => {
962
+ const contactBubbleMessageID = (userMessageID, bubbleIndex) => `${userMessageID}:bubble:${bubbleIndex + 1}`;
963
+ const contactCardMessageID = (userMessageID, index) => `${userMessageID}:card:${index + 1}`;
964
+
965
+ const persistPublishedContactBubble = (assistantID, { userMessageID, turnID, bubbleIndex, text }) => {
966
+ const trimmed = typeof text === 'string' ? text.trim() : '';
967
+ if (!trimmed || trimmed.startsWith('oc.settle.')) return false;
968
+ const bubbleID = contactBubbleMessageID(userMessageID, bubbleIndex);
969
+ if (getContactMessage(db, bubbleID)) return false;
970
+ db.exec('BEGIN IMMEDIATE');
971
+ try {
972
+ if (getContactMessage(db, bubbleID)) {
973
+ db.exec('COMMIT');
974
+ return false;
975
+ }
976
+ insertContactMessage(db, {
977
+ messageID: bubbleID,
978
+ assistantID,
979
+ role: 'assistant',
980
+ turnID,
981
+ bubbleIndex,
982
+ createdAt: now(),
983
+ ordinal: nextContactOrdinal(db, assistantID),
984
+ status: 'complete',
985
+ parts: [{ type: 'text', text: trimmed }],
986
+ });
987
+ bump();
988
+ db.exec('COMMIT');
989
+ return true;
990
+ } catch (error) {
991
+ db.exec('ROLLBACK');
992
+ throw error;
993
+ }
994
+ };
995
+
996
+ const persistPublishedContactCard = (assistantID, { userMessageID, turnID, index, card: cardInput }) => {
997
+ const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
998
+ if (!card) return false;
999
+ const cardID = contactCardMessageID(userMessageID, index);
1000
+ if (getContactMessage(db, cardID)) return false;
1001
+ db.exec('BEGIN IMMEDIATE');
1002
+ try {
1003
+ if (getContactMessage(db, cardID)) {
1004
+ db.exec('COMMIT');
1005
+ return false;
1006
+ }
1007
+ insertContactMessage(db, {
1008
+ messageID: cardID,
1009
+ assistantID,
1010
+ role: 'assistant',
1011
+ turnID,
1012
+ bubbleIndex: 0,
1013
+ createdAt: now(),
1014
+ ordinal: nextContactOrdinal(db, assistantID),
1015
+ status: 'complete',
1016
+ parts: [card],
1017
+ });
1018
+ if (card.cardType === 'session' && card.sessionID) {
1019
+ upsertContactWatch(db, {
1020
+ assistantID,
1021
+ sessionID: card.sessionID,
1022
+ directory: card.directory,
1023
+ status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
1024
+ updatedAt: now(),
1025
+ });
1026
+ }
1027
+ bump();
1028
+ db.exec('COMMIT');
1029
+ return true;
1030
+ } catch (error) {
1031
+ db.exec('ROLLBACK');
1032
+ throw error;
1033
+ }
1034
+ };
1035
+
1036
+ const publishContactBubbleDelta = ({
1037
+ assistantID, turnID, userMessageID, spokenByIndex, bubbleIndex, delta, done, signal,
1038
+ }) => {
1039
+ if (signal?.aborted) return;
1040
+ const chunk = typeof delta === 'string' ? delta : '';
1041
+ if (chunk) spokenByIndex.set(bubbleIndex, `${spokenByIndex.get(bubbleIndex) || ''}${chunk}`);
1042
+ emitContactTurnEvent('openchamber:contact-bubble-delta', {
1043
+ assistantID,
1044
+ turnID,
1045
+ bubbleIndex,
1046
+ delta: chunk,
1047
+ done: Boolean(done),
1048
+ });
1049
+ if (!done) return;
1050
+ try {
1051
+ persistPublishedContactBubble(assistantID, {
1052
+ userMessageID,
1053
+ turnID,
1054
+ bubbleIndex,
1055
+ text: spokenByIndex.get(bubbleIndex) || '',
1056
+ });
1057
+ } catch {
1058
+ // Publishing a spoken bubble must not abort the live turn.
1059
+ }
1060
+ };
1061
+
1062
+ const rememberPublishedContactCard = (assistantID, { userMessageID, turnID, assignedCards, card }) => {
1063
+ assignedCards.push(card);
1064
+ try {
1065
+ persistPublishedContactCard(assistantID, {
1066
+ userMessageID,
1067
+ turnID,
1068
+ index: assignedCards.length - 1,
1069
+ card,
1070
+ });
1071
+ } catch {
1072
+ // Publishing a card must not abort the live turn.
1073
+ }
1074
+ };
1075
+
1076
+ /** Persist remaining assistant bubbles/cards for an already-admitted user turn (no user row). */
1077
+ const persistContactAssistantReply = (assistantID, {
1078
+ userMessageID, bubbles, cards = [], turnID, reset = false,
1079
+ }) => {
887
1080
  db.exec('BEGIN IMMEDIATE');
888
1081
  try {
1082
+ // Successful new_conversation / clear_chat_history: drop this turn's pre-reset
1083
+ // spoken/card rows so the unique confirm can own bubble:1 (same id is not skipped).
1084
+ if (reset) {
1085
+ deleteContactTurnAssistantOutputs(db, assistantID, turnID);
1086
+ }
889
1087
  let ordinal = nextContactOrdinal(db, assistantID);
890
1088
  bubbles.forEach((text, index) => {
891
- const bubbleID = `${userMessageID}:bubble:${index + 1}`;
1089
+ const bubbleID = contactBubbleMessageID(userMessageID, index);
1090
+ if (getContactMessage(db, bubbleID)) return;
892
1091
  insertContactMessage(db, {
893
1092
  messageID: bubbleID,
894
1093
  assistantID,
@@ -905,8 +1104,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
905
1104
  cards.forEach((cardInput, index) => {
906
1105
  const card = parseContactCard({ type: 'card', cardType: cardInput?.cardType || 'session', ...cardInput });
907
1106
  if (!card) return;
1107
+ const cardID = contactCardMessageID(userMessageID, index);
1108
+ if (getContactMessage(db, cardID)) return;
908
1109
  insertContactMessage(db, {
909
- messageID: `${userMessageID}:card:${index + 1}`,
1110
+ messageID: cardID,
910
1111
  assistantID,
911
1112
  role: 'assistant',
912
1113
  turnID,
@@ -1005,6 +1206,23 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1005
1206
  throw wrapped;
1006
1207
  }
1007
1208
  };
1209
+ const loadContactModelContext = async (signal) => {
1210
+ const [catalogResult, preferencesResult] = await Promise.allSettled([
1211
+ loadAssignCatalog(signal),
1212
+ awaitWithDeadline(Promise.resolve().then(() => readModelPreferences()), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error'),
1213
+ ]);
1214
+ const catalog = catalogResult.status === 'fulfilled' ? catalogResult.value : null;
1215
+ const providerNames = new Map((catalog?.providers || []).map((provider) => [provider.id, provider.name]));
1216
+ const preferences = preferencesResult.status === 'fulfilled' ? preferencesResult.value : null;
1217
+ return {
1218
+ connectedModels: normalizeConnectedModels((catalog?.models || []).map((model) => ({ ...model, providerName: providerNames.get(model.providerID) }))),
1219
+ modelCatalogAvailable: catalog !== null,
1220
+ modelPreferences: preferences && typeof preferences === 'object' ? {
1221
+ favoriteModels: Array.isArray(preferences.favoriteModels) ? preferences.favoriteModels.slice(0, 64) : [],
1222
+ recentModels: Array.isArray(preferences.recentModels) ? preferences.recentModels.slice(0, 16) : [],
1223
+ } : null,
1224
+ };
1225
+ };
1008
1226
  const loadRegisteredProjects = async () => {
1009
1227
  const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
1010
1228
  if (!Array.isArray(listed)) {
@@ -1097,6 +1315,231 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1097
1315
  return null;
1098
1316
  }
1099
1317
  };
1318
+ /**
1319
+ * Find a session row in the OpenChamber session index (directory is index-authoritative).
1320
+ * Index failure throws — never silent empty.
1321
+ */
1322
+ const findSessionInIndex = (sessionID) => {
1323
+ if (!sessionIndexService || typeof sessionIndexService.snapshot !== 'function') {
1324
+ return null;
1325
+ }
1326
+ let snapshot;
1327
+ try {
1328
+ snapshot = sessionIndexService.snapshot();
1329
+ } catch (error) {
1330
+ throw new AssignError(
1331
+ ASSIGN_CODES.UPSTREAM,
1332
+ typeof error?.message === 'string' && error.message.trim()
1333
+ ? error.message.trim()
1334
+ : 'Session index snapshot failed.',
1335
+ );
1336
+ }
1337
+ if (!snapshot || !Array.isArray(snapshot.directories)) {
1338
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session index snapshot failed.');
1339
+ }
1340
+ for (const directory of snapshot.directories) {
1341
+ const dir = typeof directory?.directory === 'string' ? directory.directory : '';
1342
+ if (!dir) continue;
1343
+ for (const session of Array.isArray(directory.sessions) ? directory.sessions : []) {
1344
+ if (!session || typeof session.id !== 'string' || session.id !== sessionID) continue;
1345
+ return {
1346
+ sessionID,
1347
+ directory: dir,
1348
+ title: typeof session.title === 'string' ? session.title : null,
1349
+ updatedAt: typeof session.time?.updated === 'number'
1350
+ ? session.time.updated
1351
+ : (typeof session.updatedAt === 'number' ? session.updatedAt : 0),
1352
+ };
1353
+ }
1354
+ }
1355
+ return null;
1356
+ };
1357
+ /**
1358
+ * Resolve an existing OpenCode session for watch / stop / assign-reuse.
1359
+ * Authority order: session index directory → session.get metadata → registered project.
1360
+ * User-supplied directory/projectPath is a lookup hint only — never trusted as scope.
1361
+ * Failures stay failures (not empty success).
1362
+ */
1363
+ const resolveReferencedSession = async ({
1364
+ sessionID: rawSessionID,
1365
+ directoryHint = null,
1366
+ titleHint = null,
1367
+ signal,
1368
+ includeMessages = true,
1369
+ } = {}) => {
1370
+ const sessionID = typeof rawSessionID === 'string' ? rawSessionID.trim() : '';
1371
+ if (!sessionID) {
1372
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'sessionID is required.');
1373
+ }
1374
+ const options = signal ? { signal } : undefined;
1375
+ const indexRow = findSessionInIndex(sessionID);
1376
+ const hint = typeof directoryHint === 'string' && directoryHint.trim()
1377
+ ? directoryHint.trim()
1378
+ : null;
1379
+ // Prefer index directory; user path is last-resort get hint only.
1380
+ const getHints = [];
1381
+ if (indexRow?.directory) getHints.push(indexRow.directory);
1382
+ if (hint && !getHints.some((item) => directoryContained(item, hint) || directoryContained(hint, item))) {
1383
+ getHints.push(hint);
1384
+ }
1385
+ if (getHints.length === 0) getHints.push(null);
1386
+
1387
+ let getResult = null;
1388
+ let usedDirectoryHint = null;
1389
+ let lastError = null;
1390
+ for (const dir of getHints) {
1391
+ try {
1392
+ getResult = await client().session.get({
1393
+ sessionID,
1394
+ ...(dir ? { directory: dir } : {}),
1395
+ }, options);
1396
+ usedDirectoryHint = dir;
1397
+ if (getResult && !getResult.error) break;
1398
+ if (isMissing(getResult)) {
1399
+ getResult = { missing: true, error: getResult?.error };
1400
+ break;
1401
+ }
1402
+ // Non-404 error with a directory may be wrong scope — try next hint.
1403
+ if (getResult?.error && dir) {
1404
+ lastError = getResult.error;
1405
+ getResult = null;
1406
+ continue;
1407
+ }
1408
+ break;
1409
+ } catch (error) {
1410
+ lastError = error;
1411
+ getResult = null;
1412
+ }
1413
+ }
1414
+ if (!getResult) {
1415
+ throw new AssignError(
1416
+ ASSIGN_CODES.UPSTREAM,
1417
+ typeof lastError?.message === 'string' && lastError.message.trim()
1418
+ ? lastError.message.trim()
1419
+ : 'Failed to load that coding session.',
1420
+ );
1421
+ }
1422
+ if (getResult.missing || isMissing(getResult)) {
1423
+ throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
1424
+ }
1425
+ if (getResult.error) {
1426
+ throw new AssignError(
1427
+ ASSIGN_CODES.UPSTREAM,
1428
+ typeof getResult.error?.message === 'string' && getResult.error.message.trim()
1429
+ ? getResult.error.message.trim()
1430
+ : 'Failed to load that coding session.',
1431
+ );
1432
+ }
1433
+ const sessionPayload = getResult.data ?? getResult;
1434
+ const metaDirectory = extractSessionDirectory(sessionPayload);
1435
+ const directoryRaw = metaDirectory || indexRow?.directory || usedDirectoryHint;
1436
+ if (!directoryRaw) {
1437
+ throw new AssignError(
1438
+ ASSIGN_CODES.VALIDATION,
1439
+ 'That session has no resolvable project directory.',
1440
+ );
1441
+ }
1442
+ // Registered project scope = allowed roots (same gate as assign create).
1443
+ // Reject managed assistant-workspaces and paths outside Settings projects.
1444
+ let directory;
1445
+ try {
1446
+ directory = resolveAssignDirectory({
1447
+ projectPath: directoryRaw,
1448
+ directory: directoryRaw,
1449
+ branch: null,
1450
+ allowedRoots: getAllowedRoots(),
1451
+ managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
1452
+ worktrees: [],
1453
+ });
1454
+ } catch (error) {
1455
+ if (error instanceof AssignError) throw error;
1456
+ throw new AssignError(
1457
+ ASSIGN_CODES.WORKSPACE_FORBIDDEN,
1458
+ 'That session is not under a registered project. Add the project in Settings first.',
1459
+ );
1460
+ }
1461
+
1462
+ let messagesResult = null;
1463
+ if (includeMessages && typeof client().session.messages === 'function') {
1464
+ try {
1465
+ messagesResult = await client().session.messages({
1466
+ sessionID,
1467
+ directory,
1468
+ limit: 100,
1469
+ }, options);
1470
+ if (messagesResult?.error && !isMissing(messagesResult)) {
1471
+ // Message fetch failure must not invent status; keep messages null.
1472
+ messagesResult = null;
1473
+ }
1474
+ } catch {
1475
+ messagesResult = null;
1476
+ }
1477
+ }
1478
+ const messageRows = Array.isArray(messagesResult?.data)
1479
+ ? messagesResult.data
1480
+ : (Array.isArray(messagesResult) ? messagesResult : null);
1481
+ const status = mapSessionToWatchStatus({
1482
+ session: sessionPayload,
1483
+ messages: messageRows,
1484
+ missing: false,
1485
+ });
1486
+ const title = (typeof titleHint === 'string' && titleHint.trim() ? titleHint.trim() : null)
1487
+ || extractSessionTitle(sessionPayload)
1488
+ || indexRow?.title
1489
+ || sessionID;
1490
+ return {
1491
+ sessionID,
1492
+ directory,
1493
+ title,
1494
+ status,
1495
+ session: sessionPayload,
1496
+ messages: messageRows,
1497
+ };
1498
+ };
1499
+ const readSessionWork = async (params = {}) => {
1500
+ const limit = params.limit === undefined ? 20 : params.limit;
1501
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new AssignError(ASSIGN_CODES.VALIDATION, 'limit must be an integer from 1 to 50.');
1502
+ 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.');
1503
+ const { signal } = params;
1504
+ signal?.throwIfAborted();
1505
+ const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
1506
+ const result = await client().session.messages({
1507
+ sessionID: resolved.sessionID, directory: resolved.directory, limit,
1508
+ ...(params.before ? { before: params.before } : {}),
1509
+ }, signal ? { signal } : undefined);
1510
+ signal?.throwIfAborted();
1511
+ if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
1512
+ if (result?.error || !Array.isArray(result?.data)) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
1513
+ const rows = result.data;
1514
+ if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
1515
+ let remaining = 24000;
1516
+ let partial = rows.length > limit;
1517
+ const text = (value) => {
1518
+ if (typeof value !== 'string') return '';
1519
+ const kept = value.slice(0, Math.min(4000, remaining));
1520
+ remaining -= kept.length;
1521
+ if (kept.length < value.length) partial = true;
1522
+ return kept;
1523
+ };
1524
+ const messages = rows.slice(-limit).map((entry) => {
1525
+ const info = entry.info ?? entry;
1526
+ const parts = [];
1527
+ const sourceParts = Array.isArray(entry.parts) ? entry.parts : [];
1528
+ if (sourceParts.length > 20) partial = true;
1529
+ for (const part of sourceParts.slice(0, 20)) {
1530
+ if (part.type === 'text') parts.push({ type: 'text', text: text(part.text) });
1531
+ else if (part.type === 'file') { parts.push({ type: 'file', mime: part.mime, filename: text(part.filename) }); partial = true; }
1532
+ else if (part.type === 'tool') {
1533
+ parts.push({ type: 'tool', tool: text(part.tool), status: part.state?.status, output: text(part.state?.output), error: text(part.state?.error) });
1534
+ partial = true; // Inputs and binary details are intentionally omitted.
1535
+ } else if (part.type !== 'step-start' && part.type !== 'step-finish') partial = true;
1536
+ }
1537
+ return { messageID: info.id, role: info.role, parts };
1538
+ });
1539
+ const cursor = result.response?.headers?.get('x-next-cursor');
1540
+ const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
1541
+ return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
1542
+ };
1100
1543
  const assignWork = async (row, params = {}) => {
1101
1544
  const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
1102
1545
  const signal = params.signal;
@@ -1110,6 +1553,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1110
1553
  const reuseSessionID = typeof params.sessionID === 'string' && params.sessionID.trim()
1111
1554
  ? params.sessionID.trim()
1112
1555
  : '';
1556
+ // Reuse: resolve directory/project from authoritative session metadata first.
1557
+ // User projectPath/directory are hints only and never override scope.
1558
+ let reuseScope = null;
1559
+ if (reuseSessionID) {
1560
+ const directoryHint = typeof params.directory === 'string' && params.directory.trim()
1561
+ ? params.directory.trim()
1562
+ : (typeof params.projectPath === 'string' && params.projectPath.trim()
1563
+ ? params.projectPath.trim()
1564
+ : null);
1565
+ reuseScope = await resolveReferencedSession({
1566
+ sessionID: reuseSessionID,
1567
+ directoryHint,
1568
+ titleHint: typeof params.title === 'string' ? params.title : null,
1569
+ signal,
1570
+ // Messages only needed when following session model without explicit selection.
1571
+ includeMessages: !explicitModel,
1572
+ });
1573
+ }
1113
1574
  // Reused session without explicit model may follow the session's last model (catalog-gated).
1114
1575
  const followSessionModel = Boolean(reuseSessionID && !explicitModel);
1115
1576
  const needsCatalog = explicitModel || hasAssignImageParts(fileParts) || followSessionModel;
@@ -1127,17 +1588,18 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1127
1588
  catalog = await loadAssignCatalog(signal);
1128
1589
  }
1129
1590
  }
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,
1591
+ if (followSessionModel && catalog && reuseScope) {
1592
+ sessionModel = extractAssignSessionModel({
1593
+ session: reuseScope.session,
1594
+ messages: reuseScope.messages,
1140
1595
  });
1596
+ if (!sessionModel) {
1597
+ sessionModel = await lookupReusedSessionModel({
1598
+ sessionID: reuseSessionID,
1599
+ directory: reuseScope.directory,
1600
+ signal,
1601
+ });
1602
+ }
1141
1603
  }
1142
1604
  const workerModel = resolveAssignWorkerModel({
1143
1605
  providerID: params.providerID,
@@ -1158,6 +1620,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1158
1620
  // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
1159
1621
  return assignSession({
1160
1622
  ...params,
1623
+ ...(reuseScope ? {
1624
+ sessionID: reuseScope.sessionID,
1625
+ directory: reuseScope.directory,
1626
+ projectPath: reuseScope.directory,
1627
+ // Drop untrusted branch when reusing — directory is already authoritative.
1628
+ branch: undefined,
1629
+ title: typeof params.title === 'string' && params.title.trim()
1630
+ ? params.title.trim()
1631
+ : reuseScope.title,
1632
+ } : {}),
1161
1633
  fileParts,
1162
1634
  ...(params.variant !== undefined ? { variant: params.variant } : {}),
1163
1635
  ...(signal ? { signal } : {}),
@@ -1184,6 +1656,160 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1184
1656
  messageID: params?.messageID || `msg_assign_${id()}`,
1185
1657
  });
1186
1658
  };
1659
+ /** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
1660
+ const watchSessionWork = async (_row, params = {}) => {
1661
+ const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1662
+ if (!sessionID) {
1663
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'watch_session requires sessionID.');
1664
+ }
1665
+ const resolved = await resolveReferencedSession({
1666
+ sessionID,
1667
+ // directory/projectPath from the model are never trusted as scope authority.
1668
+ directoryHint: null,
1669
+ titleHint: typeof params.title === 'string' ? params.title : null,
1670
+ signal: params.signal,
1671
+ includeMessages: true,
1672
+ });
1673
+ return {
1674
+ sessionID: resolved.sessionID,
1675
+ directory: resolved.directory,
1676
+ title: resolved.title,
1677
+ status: resolved.status,
1678
+ watched: true,
1679
+ reused: true,
1680
+ };
1681
+ };
1682
+ /** Abort an existing coding session via real OpenCode session.abort. */
1683
+ const stopSessionWork = async (_row, params = {}) => {
1684
+ const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1685
+ if (!sessionID) {
1686
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'stop_session requires sessionID.');
1687
+ }
1688
+ const resolved = await resolveReferencedSession({
1689
+ sessionID,
1690
+ directoryHint: null,
1691
+ signal: params.signal,
1692
+ // Abort does not need messages for baseline.
1693
+ includeMessages: false,
1694
+ });
1695
+ const signal = params.signal;
1696
+ signal?.throwIfAborted();
1697
+ if (typeof client().session.abort !== 'function') {
1698
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session abort is unavailable.');
1699
+ }
1700
+ const targetID = resolved.sessionID;
1701
+ // Share one abort lifecycle per worker session (register before await abort).
1702
+ const existing = sessionAbortInflight.get(targetID);
1703
+ if (existing?.promise) {
1704
+ const shared = await existing.promise;
1705
+ if (shared?.ok) {
1706
+ return {
1707
+ sessionID: targetID,
1708
+ directory: resolved.directory,
1709
+ title: resolved.title,
1710
+ aborted: true,
1711
+ };
1712
+ }
1713
+ throw shared?.error || new AssignError(ASSIGN_CODES.UPSTREAM, 'Failed to abort that coding session.');
1714
+ }
1715
+ const entry = { sessionID: targetID, observed: null, promise: null };
1716
+ sessionAbortInflight.set(targetID, entry);
1717
+ const failAbort = (code, message) => new AssignError(
1718
+ code,
1719
+ typeof message === 'string' && message.trim() ? message.trim() : 'Failed to abort that coding session.',
1720
+ );
1721
+ entry.promise = (async () => {
1722
+ const sdkOptions = signal ? { signal } : undefined;
1723
+ let result;
1724
+ try {
1725
+ signal?.throwIfAborted();
1726
+ result = await client().session.abort({
1727
+ sessionID: targetID,
1728
+ directory: resolved.directory,
1729
+ }, sdkOptions);
1730
+ } catch (error) {
1731
+ const observed = entry.observed;
1732
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1733
+ if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
1734
+ const wrapped = error?.name === 'AbortError' || signal?.aborted
1735
+ ? error
1736
+ : failAbort(
1737
+ ASSIGN_CODES.UPSTREAM,
1738
+ typeof error?.message === 'string' ? error.message : '',
1739
+ );
1740
+ return { ok: false, error: wrapped };
1741
+ }
1742
+ if (isMissing(result)) {
1743
+ const observed = entry.observed;
1744
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1745
+ if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
1746
+ return { ok: false, error: failAbort(ASSIGN_CODES.NOT_FOUND, `No coding session ${targetID} was found.`) };
1747
+ }
1748
+ if (result?.error || result?.data === false) {
1749
+ const observed = entry.observed;
1750
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1751
+ if (observed) reportAssignedSession(targetID, observed.status, observed.resume !== false);
1752
+ return {
1753
+ ok: false,
1754
+ error: failAbort(
1755
+ ASSIGN_CODES.UPSTREAM,
1756
+ typeof result.error?.message === 'string' ? result.error.message : '',
1757
+ ),
1758
+ };
1759
+ }
1760
+ // Success (including silent abort with no SSE): release map first so reporter applies.
1761
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1762
+ cancelAssignedResumes({ sessionID: targetID });
1763
+ // Assistant already owns this user turn; do not schedule a second cancelled notify.
1764
+ reportAssignedSession(targetID, 'cancelled', false);
1765
+ return { ok: true };
1766
+ })();
1767
+ try {
1768
+ const outcome = await entry.promise;
1769
+ if (outcome?.ok) {
1770
+ return {
1771
+ sessionID: targetID,
1772
+ directory: resolved.directory,
1773
+ title: resolved.title,
1774
+ aborted: true,
1775
+ };
1776
+ }
1777
+ throw outcome?.error || failAbort(ASSIGN_CODES.UPSTREAM, '');
1778
+ } finally {
1779
+ if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
1780
+ }
1781
+ };
1782
+ // Resolve scope from the authoritative session, never model-supplied directories.
1783
+ const mutateSessionWork = async (operation, params = {}) => {
1784
+ const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
1785
+ if (!sessionID) throw new AssignError(ASSIGN_CODES.VALIDATION, `${operation} requires sessionID.`);
1786
+ if (operation === 'steer' && (typeof params.text !== 'string' || !params.text.trim())) {
1787
+ throw new AssignError(ASSIGN_CODES.VALIDATION, 'steer requires text.');
1788
+ }
1789
+ const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
1790
+ params.signal?.throwIfAborted();
1791
+ const session = client().session;
1792
+ const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
1793
+ if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
1794
+ const request = { sessionID, directory: resolved.directory };
1795
+ if (operation === 'steer') Object.assign(request, {
1796
+ messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
1797
+ });
1798
+ if (operation === 'archive') request.time = { archived: now() };
1799
+ let result;
1800
+ try {
1801
+ result = await session[method](request, params.signal ? { signal: params.signal } : undefined);
1802
+ } catch (error) {
1803
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
1804
+ }
1805
+ if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
1806
+ if (result?.error || result?.data === false || (operation === 'steer' && !promptAdmitted(result))) {
1807
+ // Do not retry an ambiguous prompt submission: it may already be admitted upstream.
1808
+ throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
1809
+ }
1810
+ if (operation !== 'steer') reportAssignedSession(sessionID, 'cancelled');
1811
+ return { sessionID, directory: resolved.directory, operation, ...(operation === 'steer' ? { messageID: request.messageID, admitted: true } : { [operation === 'archive' ? 'archived' : 'deleted']: true }) };
1812
+ };
1187
1813
  const matchRegisteredProject = async (directory) => {
1188
1814
  const projects = await listProjects();
1189
1815
  const resolved = path.resolve(directory);
@@ -1457,6 +2083,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1457
2083
  `sessionID: ${sessionID}`,
1458
2084
  `status: ${status}`,
1459
2085
  ];
2086
+ if (status === 'cancelled') {
2087
+ lines.push(
2088
+ 'reason: user_interrupted',
2089
+ 'The user manually interrupted this assigned worker session (OpenCode session.abort / MessageAbortedError). This is not a model failure and not an assistant-initiated stop_session.',
2090
+ );
2091
+ }
1460
2092
  if (title) lines.push(`cardTitle: ${title}`);
1461
2093
  if (workerText) {
1462
2094
  lines.push('lastWorkerAssistantText (bounded):');
@@ -1466,19 +2098,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1466
2098
  }
1467
2099
  lines.push(
1468
2100
  'Instructions:',
1469
- '- 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.',
1471
- '- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
2101
+ '- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed" / "会话已取消" / "Session cancelled".',
2102
+ '- This is a read-only result notification, never authorization to create, restart, continue, steer, or otherwise mutate any session or file.',
2103
+ '- Respect the latest user instructions in history, including stop/cancel. If work remains, report it and wait for an explicit user request.',
1472
2104
  );
2105
+ if (status === 'cancelled') {
2106
+ lines.push(
2107
+ '- The user interrupted this worker themselves. In the user\'s language, acknowledge that interruption and decide the next spoken step (usually stop here and wait). Do not restart the worker unless history already asked you to.',
2108
+ );
2109
+ } else {
2110
+ lines.push(
2111
+ '- If no further work is needed, reply in one or two short sentences in the user\'s language summarizing the result or failure reason.',
2112
+ );
2113
+ }
1473
2114
  return lines.join('\n');
1474
2115
  };
1475
2116
  /**
1476
- * After assigned worker complete/error: async contact-lane continuation.
2117
+ * After assigned worker complete/error/cancelled: async contact-lane continuation.
1477
2118
  * No user transcript row — only internal runContactTurn userText + assistant bubbles/cards.
1478
2119
  */
1479
2120
  const scheduleAssignedSessionResume = ({ assistantID, sessionID, status, directory, updatedAt }) => {
1480
2121
  if (closed) return;
1481
- if (status !== 'complete' && status !== 'error') return;
2122
+ if (!assignedSessionResumeStatus(status)) return;
1482
2123
  const row = assistant(assistantID);
1483
2124
  if (!row || row.tombstone_at) return;
1484
2125
  const messageID = assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
@@ -1489,6 +2130,15 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1489
2130
  || getContactMessage(db, `${messageID}:error`)
1490
2131
  ) return;
1491
2132
 
2133
+ if (assignedResumes.has(messageID)) return;
2134
+ const controller = new AbortController();
2135
+ assignedResumes.set(messageID, { assistantID, sessionID, status, controller });
2136
+ const finishCancelled = () => {
2137
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
2138
+ emitContactTurnEvent('openchamber:contact-turn-end', { assistantID, turnID, status: 'cancelled' });
2139
+ resolveContactTurnSettlement(messageID, { status: 'cancelled' });
2140
+ };
2141
+
1492
2142
  rememberActiveContactTurn(assistantID, {
1493
2143
  turnID,
1494
2144
  messageID,
@@ -1505,6 +2155,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1505
2155
  contactTurnSettlement(messageID);
1506
2156
 
1507
2157
  void inContactTurnLane(assistantID, async () => {
2158
+ if (controller.signal.aborted) { finishCancelled(); return; }
1508
2159
  if (closed) {
1509
2160
  settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1510
2161
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1532,13 +2183,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1532
2183
  } catch {
1533
2184
  registeredProjects = [];
1534
2185
  }
1535
- let connectedModels = [];
1536
- try {
1537
- const catalog = await loadAssignCatalog(undefined);
1538
- connectedModels = normalizeConnectedModels(catalog?.models);
1539
- } catch {
1540
- connectedModels = [];
1541
- }
2186
+ const modelContext = await loadContactModelContext(controller.signal);
1542
2187
 
1543
2188
  let workerText = '';
1544
2189
  try {
@@ -1560,6 +2205,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1560
2205
  const rows = Array.isArray(messagesResult?.data)
1561
2206
  ? messagesResult.data
1562
2207
  : (Array.isArray(messagesResult) ? messagesResult : []);
2208
+ // complete/error that discovers a user abort becomes cancelled + notify.
2209
+ // A cancelled resume must still run with reason: user_interrupted.
2210
+ if (status !== 'cancelled' && isUserAbort(lastAssistantInfo(rows)?.error)) {
2211
+ reportAssignedSession(sessionID, 'cancelled');
2212
+ finishCancelled();
2213
+ return;
2214
+ }
1563
2215
  workerText = extractLastWorkerAssistantText(rows);
1564
2216
  }
1565
2217
  }
@@ -1567,6 +2219,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1567
2219
  workerText = '';
1568
2220
  }
1569
2221
 
2222
+ if (controller.signal.aborted) { finishCancelled(); return; }
1570
2223
  const title = findAssignedSessionCardTitle(assistantID, sessionID);
1571
2224
  const userText = buildAssignedSessionResumeUserText({
1572
2225
  sessionID,
@@ -1575,9 +2228,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1575
2228
  workerText,
1576
2229
  });
1577
2230
  const history = contactHistoryForLlm(db, assistantID, {});
1578
- const assignedCards = [];
1579
- let contactResetThisTurn = false;
1580
- let contactHistoryClearedThisTurn = false;
2231
+ const assignedCards = [];
2232
+ const spokenByIndex = new Map();
2233
+ let contactResetThisTurn = false;
2234
+ let contactHistoryClearedThisTurn = false;
2235
+ const markContactReset = (historyCleared = false) => {
2236
+ contactResetThisTurn = true;
2237
+ if (historyCleared) contactHistoryClearedThisTurn = true;
2238
+ spokenByIndex.clear();
2239
+ assignedCards.length = 0;
2240
+ };
1581
2241
 
1582
2242
  try {
1583
2243
  const executionHistory = await materializeContactHistory(
@@ -1589,23 +2249,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1589
2249
  );
1590
2250
  const tools = createContactTools({
1591
2251
  assignWork: (params) => assignWork(live, params),
2252
+ watchSession: (params) => watchSessionWork(live, params),
2253
+ stopSession: (params) => stopSessionWork(live, params),
2254
+ steerSession: (params) => mutateSessionWork('steer', params),
2255
+ archiveSession: (params) => mutateSessionWork('archive', params),
2256
+ deleteSession: (params) => mutateSessionWork('delete', params),
1592
2257
  createAssistant: (toolInput) => createAssistant(toolInput),
1593
2258
  scheduleTask: (params) => scheduleWork(live, params),
1594
2259
  deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
1595
2260
  clearContactMemory: () => {
1596
2261
  const result = clearContactMemory(assistantID, {});
1597
- contactResetThisTurn = true;
2262
+ markContactReset(false);
1598
2263
  return result;
1599
2264
  },
1600
2265
  resetContact: () => {
1601
2266
  const result = resetContact(assistantID, {});
1602
- contactResetThisTurn = true;
1603
- contactHistoryClearedThisTurn = true;
2267
+ markContactReset(true);
1604
2268
  return result;
1605
2269
  },
1606
2270
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1607
2271
  listProjects: async () => loadRegisteredProjects(),
1608
2272
  listSessions: (params) => listSessionsWork(params),
2273
+ readSession: (params) => readSessionWork(params),
1609
2274
  readAssistantSettings: (input = {}) => {
1610
2275
  const targetID = typeof input?.assistantID === 'string' && input.assistantID.trim()
1611
2276
  ? input.assistantID.trim()
@@ -1655,30 +2320,37 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1655
2320
  throw lastError;
1656
2321
  },
1657
2322
  currentAssistant: assistantSnapshot,
1658
- onCard: (card) => assignedCards.push(card),
2323
+ onCard: (card) => {
2324
+ if (contactResetThisTurn) return;
2325
+ rememberPublishedContactCard(assistantID, {
2326
+ userMessageID: messageID, turnID, assignedCards, card,
2327
+ });
2328
+ },
1659
2329
  turnFileParts: [],
1660
2330
  turnAttachmentScope: attachmentScopeKey([]),
1661
2331
  });
2332
+ controller.signal.throwIfAborted();
1662
2333
  const generated = await runContactTurn({
2334
+ signal: controller.signal,
2335
+ readOnly: true,
1663
2336
  assistant: assistantSnapshot,
1664
2337
  history: executionHistory,
1665
2338
  userText,
1666
2339
  userParts: [{ type: 'text', text: userText }],
2340
+ language: contactTurnLanguages.get(assistantID) ?? '',
1667
2341
  createChatCompletion,
1668
2342
  tools,
1669
2343
  projects: registeredProjects,
1670
- connectedModels,
2344
+ ...modelContext,
1671
2345
  globalEventHub,
1672
2346
  onBubbleDelta: (bubbleIndex, delta, done) => {
1673
- emitContactTurnEvent('openchamber:contact-bubble-delta', {
1674
- assistantID,
1675
- turnID,
1676
- bubbleIndex,
1677
- delta: typeof delta === 'string' ? delta : '',
1678
- done: Boolean(done),
2347
+ if (contactResetThisTurn) return;
2348
+ publishContactBubbleDelta({
2349
+ assistantID, turnID, userMessageID: messageID, spokenByIndex, bubbleIndex, delta, done, signal: controller.signal,
1679
2350
  });
1680
2351
  },
1681
2352
  });
2353
+ if (controller.signal.aborted) { finishCancelled(); return; }
1682
2354
  if (closed) {
1683
2355
  settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1684
2356
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1734,6 +2406,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1734
2406
  bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
1735
2407
  cards,
1736
2408
  turnID,
2409
+ reset: resetThisTurn,
1737
2410
  });
1738
2411
  settleActiveContactTurn(assistantID, turnID);
1739
2412
  emitContactTurnEvent('openchamber:contact-turn-end', {
@@ -1751,6 +2424,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1751
2424
  });
1752
2425
  resolveContactTurnSettlement(messageID, { status: 'complete' });
1753
2426
  } catch (error) {
2427
+ if (controller.signal.aborted) { finishCancelled(); return; }
1754
2428
  const detail = typeof error?.message === 'string' && error.message.trim()
1755
2429
  ? error.message.trim()
1756
2430
  : (error?.code || 'upstream_error');
@@ -1781,7 +2455,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1781
2455
  });
1782
2456
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
1783
2457
  }
1784
- });
2458
+ }).finally(() => { assignedResumes.delete(messageID); });
1785
2459
  };
1786
2460
  assignedSessionResumeRef.schedule = scheduleAssignedSessionResume;
1787
2461
  /**
@@ -1795,7 +2469,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1795
2469
  const messageID = string(input.messageID, 256, true);
1796
2470
  const userText = extractUserText(input);
1797
2471
  if (!userText) fail('validation_error');
2472
+ const language = typeof input.language === 'string' ? input.language.trim().slice(0, 64) : '';
1798
2473
  const row = active(assistantID);
2474
+ if (language) contactTurnLanguages.set(row.assistant_id, language);
1799
2475
  const userParts = await userContactParts(row.assistant_id, input, userText);
1800
2476
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1801
2477
  let registeredProjects = [];
@@ -1825,12 +2501,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1825
2501
  replayed: true,
1826
2502
  };
1827
2503
  }
2504
+ cancelAssignedResumes({ assistantID: row.assistant_id });
2505
+ // A new user turn supersedes pending background notifications as well as running ones.
2506
+ // Explicit assign/watch admission rearms only its own watch. Keep live card status intact.
2507
+ db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(row.assistant_id);
1828
2508
  admitContactUser(row.assistant_id, {
1829
2509
  userMessageID: messageID,
1830
2510
  userText,
1831
2511
  userParts,
1832
2512
  turnID,
1833
2513
  });
2514
+ const controller = new AbortController();
2515
+ contactControllers.set(messageID, { assistantID: row.assistant_id, controller });
2516
+ const finishCancelled = () => {
2517
+ settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
2518
+ emitContactTurnEvent('openchamber:contact-turn-end', { assistantID: row.assistant_id, turnID, status: 'cancelled' });
2519
+ resolveContactTurnSettlement(messageID, { status: 'cancelled' });
2520
+ contactControllers.delete(messageID);
2521
+ };
1834
2522
  rememberActiveContactTurn(row.assistant_id, {
1835
2523
  turnID,
1836
2524
  messageID,
@@ -1845,6 +2533,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1845
2533
  contactTurnSettlement(messageID);
1846
2534
  const kickTurn = () => {
1847
2535
  void inContactTurnLane(row.assistant_id, async () => {
2536
+ if (controller.signal.aborted) { finishCancelled(); return; }
1848
2537
  if (closed) {
1849
2538
  settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1850
2539
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1865,17 +2554,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1865
2554
  beforeOrdinal,
1866
2555
  });
1867
2556
  const assignedCards = [];
2557
+ const spokenByIndex = new Map();
1868
2558
  let contactResetThisTurn = false;
1869
2559
  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
- }
2560
+ const markContactReset = (historyCleared = false) => {
2561
+ contactResetThisTurn = true;
2562
+ if (historyCleared) contactHistoryClearedThisTurn = true;
2563
+ spokenByIndex.clear();
2564
+ assignedCards.length = 0;
2565
+ };
2566
+ const modelContext = await loadContactModelContext(controller.signal);
1879
2567
  try {
1880
2568
  // Materialize inside turn try so failures durable-error + settle working.
1881
2569
  // DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
@@ -1900,6 +2588,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1900
2588
  const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1901
2589
  const tools = createContactTools({
1902
2590
  assignWork: (params) => assignWork(row, params),
2591
+ watchSession: (params) => watchSessionWork(row, params),
2592
+ stopSession: (params) => stopSessionWork(row, params),
2593
+ steerSession: (params) => mutateSessionWork('steer', params),
2594
+ archiveSession: (params) => mutateSessionWork('archive', params),
2595
+ deleteSession: (params) => mutateSessionWork('delete', params),
1903
2596
  createAssistant: (toolInput) => createAssistant(toolInput),
1904
2597
  scheduleTask: (params) => scheduleWork(row, params),
1905
2598
  deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
@@ -1909,7 +2602,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1909
2602
  const result = clearContactMemory(row.assistant_id, {
1910
2603
  upToOrdinal: beforeOrdinal,
1911
2604
  });
1912
- contactResetThisTurn = true;
2605
+ markContactReset(false);
1913
2606
  return result;
1914
2607
  },
1915
2608
  resetContact: () => {
@@ -1918,13 +2611,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1918
2611
  const result = resetContact(row.assistant_id, {
1919
2612
  upToOrdinal: beforeOrdinal,
1920
2613
  });
1921
- contactResetThisTurn = true;
1922
- contactHistoryClearedThisTurn = true;
2614
+ markContactReset(true);
1923
2615
  return result;
1924
2616
  },
1925
2617
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1926
2618
  listProjects: async () => loadRegisteredProjects(),
1927
2619
  listSessions: (params) => listSessionsWork(params),
2620
+ readSession: (params) => readSessionWork(params),
1928
2621
  // Live DB read — not the turn-start assistantSnapshot.
1929
2622
  // Optional assistantID targets another live row; omitted → this contact.
1930
2623
  readAssistantSettings: (input = {}) => {
@@ -1978,30 +2671,36 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1978
2671
  throw lastError;
1979
2672
  },
1980
2673
  currentAssistant: assistantSnapshot,
1981
- onCard: (card) => assignedCards.push(card),
2674
+ onCard: (card) => {
2675
+ if (contactResetThisTurn) return;
2676
+ rememberPublishedContactCard(row.assistant_id, {
2677
+ userMessageID: messageID, turnID, assignedCards, card,
2678
+ });
2679
+ },
1982
2680
  turnFileParts,
1983
2681
  turnAttachmentScope,
1984
2682
  });
2683
+ controller.signal.throwIfAborted();
1985
2684
  const generated = await runContactTurn({
2685
+ signal: controller.signal,
1986
2686
  assistant: assistantSnapshot,
1987
2687
  history: executionHistory,
1988
2688
  userText,
1989
2689
  userParts: executionParts,
2690
+ language,
1990
2691
  createChatCompletion,
1991
2692
  tools,
1992
2693
  projects: registeredProjects,
1993
- connectedModels,
2694
+ ...modelContext,
1994
2695
  globalEventHub,
1995
2696
  onBubbleDelta: (bubbleIndex, delta, done) => {
1996
- emitContactTurnEvent('openchamber:contact-bubble-delta', {
1997
- assistantID: row.assistant_id,
1998
- turnID,
1999
- bubbleIndex,
2000
- delta: typeof delta === 'string' ? delta : '',
2001
- done: Boolean(done),
2697
+ if (contactResetThisTurn) return;
2698
+ publishContactBubbleDelta({
2699
+ assistantID: row.assistant_id, turnID, userMessageID: messageID, spokenByIndex, bubbleIndex, delta, done, signal: controller.signal,
2002
2700
  });
2003
2701
  },
2004
2702
  });
2703
+ if (controller.signal.aborted) { finishCancelled(); return; }
2005
2704
  if (closed) {
2006
2705
  settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
2007
2706
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -2070,6 +2769,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2070
2769
  bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
2071
2770
  cards,
2072
2771
  turnID,
2772
+ reset: resetThisTurn,
2073
2773
  });
2074
2774
  // persistContactAssistantReply already bumps; clear activity without a second tip.
2075
2775
  settleActiveContactTurn(row.assistant_id, turnID);
@@ -2088,6 +2788,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2088
2788
  });
2089
2789
  resolveContactTurnSettlement(messageID, { status: 'complete' });
2090
2790
  } catch (error) {
2791
+ if (controller.signal.aborted) { finishCancelled(); return; }
2091
2792
  const detail = typeof error?.message === 'string' && error.message.trim()
2092
2793
  ? error.message.trim()
2093
2794
  : (error?.code || 'upstream_error');
@@ -2117,6 +2818,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2117
2818
  body: detail,
2118
2819
  });
2119
2820
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
2821
+ } finally {
2822
+ contactControllers.delete(messageID);
2120
2823
  }
2121
2824
  });
2122
2825
  };
@@ -2196,6 +2899,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2196
2899
  deleteContactMessages(db, row.assistant_id, {
2197
2900
  ...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
2198
2901
  });
2902
+ // Rebind read watermark to the new generation at empty cursor so post-wipe
2903
+ // confirm bubbles can surface as unread; stale generation mark-read fails closed.
2904
+ resetContactReadWatermarkForGeneration(db, row.assistant_id, generation, { updatedAt: now() });
2199
2905
  // Active contact turns stay until settle — wipe must not drop the green
2200
2906
  // dot while the clearing turn is still running.
2201
2907
  bump();
@@ -2206,6 +2912,48 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2206
2912
  throw error;
2207
2913
  }
2208
2914
  };
2915
+ /**
2916
+ * Advance the shared contact read watermark (multi-client).
2917
+ * Body: { generation, ordinal, messageID } — must match live generation;
2918
+ * only monotonic forward moves bump revision / assistants-changed.
2919
+ * Idempotent no-op when unchanged (no broadcast).
2920
+ */
2921
+ const markContactRead = (assistantID, input = {}) => {
2922
+ const row = editable(assistantID);
2923
+ if (!plainObject(input)) fail('validation_error');
2924
+ db.exec('BEGIN IMMEDIATE');
2925
+ try {
2926
+ let advanced;
2927
+ try {
2928
+ advanced = advanceContactReadWatermark(db, row.assistant_id, {
2929
+ generation: input.generation,
2930
+ ordinal: input.ordinal,
2931
+ messageID: input.messageID,
2932
+ }, { updatedAt: now() });
2933
+ } catch (error) {
2934
+ if (error?.code === 'contact_generation_conflict' || error?.code === 'validation_error') {
2935
+ fail(error.code, error.message);
2936
+ }
2937
+ throw error;
2938
+ }
2939
+ let tipRevision = revision();
2940
+ if (advanced.changed) {
2941
+ tipRevision = bump();
2942
+ }
2943
+ db.exec('COMMIT');
2944
+ return {
2945
+ assistantID: row.assistant_id,
2946
+ changed: advanced.changed,
2947
+ unreadCount: advanced.unreadCount,
2948
+ readWatermark: advanced.readWatermark,
2949
+ readTip: advanced.readTip,
2950
+ revision: tipRevision,
2951
+ };
2952
+ } catch (error) {
2953
+ db.exec('ROLLBACK');
2954
+ throw error;
2955
+ }
2956
+ };
2209
2957
  const appendContactCard = (assistantID, input) => {
2210
2958
  const row = active(assistantID);
2211
2959
  const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
@@ -2317,7 +3065,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2317
3065
  };
2318
3066
  return mode === 'stateless' ? inStatelessLane(deliveryTarget.assistantID, submit) : submit();
2319
3067
  };
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 }; };
3068
+ const abort = async (assistantID, input) => {
3069
+ const row = active(assistantID);
3070
+ if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation) fail('revision_conflict');
3071
+ let interrupted = false;
3072
+ for (const turn of contactControllers.values()) {
3073
+ if (turn.assistantID !== assistantID) continue;
3074
+ turn.controller.abort();
3075
+ interrupted = true;
3076
+ }
3077
+ for (const turn of assignedResumes.values()) {
3078
+ if (turn.assistantID !== assistantID) continue;
3079
+ turn.controller.abort();
3080
+ interrupted = true;
3081
+ }
3082
+ db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
3083
+ if (interrupted) return { binding: binding(row), aborted: true };
3084
+ if (!row.current_session_id) fail('revision_conflict');
3085
+ const result = await client().session.abort({ sessionID: row.current_session_id, directory: effectiveWorkspace(row) });
3086
+ if (isMissing(result)) fail('not_found');
3087
+ if (result.error) fail('upstream_error');
3088
+ return { binding: binding(row), aborted: true };
3089
+ };
2321
3090
  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
3091
  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
3092
  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 +3153,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2384
3153
  });
2385
3154
  const snapshot = () => {
2386
3155
  const rows = db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all();
3156
+ const assistantIDs = rows.map((row) => row.assistant_id);
2387
3157
  // Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
2388
- const previews = getLatestContactMessagePreviews(db, rows.map((row) => row.assistant_id));
3158
+ const previews = getLatestContactMessagePreviews(db, assistantIDs);
3159
+ // Batch unread/watermark/tip — no per-assistant N+1 on snapshot.
3160
+ const unreadByID = getContactUnreadSnapshots(db, assistantIDs);
2389
3161
  return {
2390
3162
  revision: revision(),
2391
3163
  enabled: enabled(),
2392
- assistants: rows.map((row) => output(row, previews.get(row.assistant_id) ?? null)),
3164
+ assistants: rows.map((row) => output(
3165
+ row,
3166
+ previews.get(row.assistant_id) ?? null,
3167
+ unreadByID.get(row.assistant_id) ?? null,
3168
+ )),
2393
3169
  };
2394
3170
  };
2395
3171
  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 +3179,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2403
3179
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
2404
3180
  db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
2405
3181
  deleteContactMessages(db, assistantID);
3182
+ deleteContactReadState(db, assistantID);
2406
3183
  clearActiveContactTurns(assistantID);
3184
+ contactTurnLanguages.delete(assistantID);
2407
3185
  bump();
2408
3186
  db.exec('COMMIT');
2409
3187
  return { assistantID, tombstoneAt: now() };
@@ -2411,11 +3189,15 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
2411
3189
  db.exec('ROLLBACK');
2412
3190
  throw error;
2413
3191
  }
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: () => {
3192
+ }, 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: () => {
3193
+ cancelAssignedResumes({});
3194
+ sessionAbortInflight.clear();
3195
+ for (const turn of contactControllers.values()) turn.controller.abort();
2415
3196
  if (!closed) {
2416
3197
  closed = true;
2417
3198
  try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
2418
3199
  clearActiveContactTurns();
3200
+ contactTurnLanguages.clear();
2419
3201
  unsubscribeEvents?.();
2420
3202
  clearIntervalFn(timer);
2421
3203
  db.close();