@openchambery/web 1.19.13-beta.3 → 1.19.13-beta.5

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 (113) hide show
  1. package/dist/assets/{AgentAvatar-mbvX7YlW.js → AgentAvatar-B5MiX004.js} +1 -1
  2. package/dist/assets/AssistantView-B6kZZdri.js +2 -0
  3. package/dist/assets/{DiagramView-W0IHFk-1.js → DiagramView-C40ituM2.js} +1 -1
  4. package/dist/assets/{FadeInOnReveal-moBbL8Ll.js → FadeInOnReveal-UGwClbL_.js} +1 -1
  5. package/dist/assets/{Icon-CkzzhaLr.js → Icon-ZAKaNHiG.js} +1 -1
  6. package/dist/assets/{MarkdownRenderer-v7n_D6nL.js → MarkdownRenderer-mcY597G0.js} +2 -2
  7. package/dist/assets/{MarkdownRendererImpl-Buz5LZsa.js → MarkdownRendererImpl-YsTrEeUY.js} +6 -3
  8. package/dist/assets/MarkstreamRendererImpl-kNx_G9Sd.js +3 -0
  9. package/dist/assets/MobileDetailNavigation-Uwzh6TWj.js +1 -0
  10. package/dist/assets/MobileShareBridge-D3oSiSC6.js +4 -0
  11. package/dist/assets/MobileSurface-B-SWjAj7.js +2 -0
  12. package/dist/assets/{MultiRunWindow-yWIHgr89.js → MultiRunWindow-LgdUonfa.js} +1 -1
  13. package/dist/assets/{OnboardingScreen-j0iCyH7f.js → OnboardingScreen-DHKq6y3W.js} +1 -1
  14. package/dist/assets/{OverlayScrollbar-CDC5B3RH.js → OverlayScrollbar-Bahg_P3r.js} +1 -1
  15. package/dist/assets/{SettingsView-C3BbLpHT.js → SettingsView-iUiWTKbM.js} +8 -8
  16. package/dist/assets/{SettingsWindow-b0JWdygM.js → SettingsWindow-DCDEAC1n.js} +1 -1
  17. package/dist/assets/{TerminalView-C74IvmOt.js → TerminalView-CdZi_UXQ.js} +1 -1
  18. package/dist/assets/{ToolOutputDialog-BORu8CtR.js → ToolOutputDialog-fZq6UHvy.js} +1 -1
  19. package/dist/assets/{appThemeRegistry-DOwhB93n.js → appThemeRegistry-DyFotcOM.js} +1 -1
  20. package/dist/assets/{button-xx1UOBA5.js → button-9R-WgVMl.js} +1 -1
  21. package/dist/assets/{clipboard-CUasgPiF.js → clipboard-CzXos-TI.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-U4ML8hxt.js} +1 -1
  28. package/dist/assets/{es-CRjOwvYM.js → es-BtC17YQH.js} +1 -1
  29. package/dist/assets/fileMentionTouchSelection-DSKcxgU_.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-DPohCCUO.js} +1 -1
  32. package/dist/assets/{gitApi-D1mvv14J.js → gitApi-BUubBKri.js} +3 -3
  33. package/dist/assets/{input-CI730njs.js → input-Cd_1HyQv.js} +1 -1
  34. package/dist/assets/insertionBoundaries-DeGd-RP9.js +1 -0
  35. package/dist/assets/{ja-CYQ5aoWp.js → ja-CflPxUNi.js} +1 -1
  36. package/dist/assets/{ko-BwASV0Q0.js → ko-CuSU80xd.js} +1 -1
  37. package/dist/assets/main-C6EluFKk.js +169 -0
  38. package/dist/assets/{main-phpzFgMN.js → main-iFGhn-zU.js} +2 -2
  39. package/dist/assets/{markdownHeightCache-DEjGKLv8.js → markdownHeightCache-BL8CUQ0M.js} +1 -1
  40. package/dist/assets/miniChat-Bs_H98PB.js +2 -0
  41. package/dist/assets/mobile-HfLUO-bG.js +2 -0
  42. package/dist/assets/{multirun-CYuxJPBX.js → multirun-BABuRg1O.js} +1 -1
  43. package/dist/assets/{persistence-YbQKzHUQ.js → persistence-DwRBC4zl.js} +2 -2
  44. package/dist/assets/{pl-DUI1vPil.js → pl-eJvVbQ74.js} +1 -1
  45. package/dist/assets/platform-DQ5_LNKO.js +2 -0
  46. package/dist/assets/{projectMeta-Cir66uF3.js → projectMeta-WcxDg0DZ.js} +2 -2
  47. package/dist/assets/{pt-BR-CZQFJllP.js → pt-BR-DSejAksY.js} +1 -1
  48. package/dist/assets/{renderElectronMiniChatApp-BzYqqr9n.js → renderElectronMiniChatApp-Cp-dVXj5.js} +2 -2
  49. package/dist/assets/renderMobileApp-B6rb6wuc.js +28 -0
  50. package/dist/assets/{runtimeConfig-BXf9GQ0Q.js → runtimeConfig-88DzHVMT.js} +1 -1
  51. package/dist/assets/runtimeEndpointReset-CEbKea6x.js +147 -0
  52. package/dist/assets/sessionChangeSummary-C9071lsK.js +2 -0
  53. package/dist/assets/sessionLookup-CEw3tCz1.js +1 -0
  54. package/dist/assets/{sortable-tabs-strip-Dom6Sthu.js → sortable-tabs-strip-eL2whsf8.js} +1 -1
  55. package/dist/assets/src-B_DG0xy5.css +2 -0
  56. package/dist/assets/{store-CSnH_QXM.js → store-DHdMj3y4.js} +2 -2
  57. package/dist/assets/{terminalApi-noa4Cdbh.js → terminalApi-CfgOfnZk.js} +1 -1
  58. package/dist/assets/{textarea-BfNtUdJE.js → textarea-DE1UrdtK.js} +1 -1
  59. package/dist/assets/{tooltip-DPxumadF.js → tooltip-DSjr26A_.js} +1 -1
  60. package/dist/assets/{uk-C1FYZlmA.js → uk-DV9OgOAg.js} +1 -1
  61. package/dist/assets/{url-CVhsI9kR.js → url-CMQwHq4k.js} +1 -1
  62. package/dist/assets/{useAppFontEffects-NVyoI_Ei.js → useAppFontEffects-Ca0mASq5.js} +43 -44
  63. package/dist/assets/{useDesktopWindowControlsLayout-CP3NmMYG.js → useDesktopWindowControlsLayout-B3KJYYwp.js} +1 -1
  64. package/dist/assets/{useEffectiveDirectory-AUoqSyOJ.js → useEffectiveDirectory-B4l9snxd.js} +1 -1
  65. package/dist/assets/{useFontPreferences-CB-cGo3I.js → useFontPreferences-DCaY8NbU.js} +1 -1
  66. package/dist/assets/useMobileNavigationStore-CU2UwmN3.js +413 -0
  67. package/dist/assets/{useSessionAutoCleanup-ClvQQPqQ.js → useSessionAutoCleanup-B9El9yjX.js} +1 -1
  68. package/dist/assets/{useWorkerHighlightedLines-DOk4cAxv.js → useWorkerHighlightedLines-PPhFeDFI.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-92n_rGTt.js} +1 -1
  72. package/dist/assets/{zh-TW-BsRk1Rbb.js → zh-TW-CwVxwJCj.js} +1 -1
  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 +104 -18
  79. package/server/lib/assistants/assign.js +173 -13
  80. package/server/lib/assistants/assign.test.js +139 -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 +514 -52
  85. package/server/lib/assistants/contact-tools.test.js +390 -7
  86. package/server/lib/assistants/contracts.js +31 -0
  87. package/server/lib/assistants/harness.js +224 -54
  88. package/server/lib/assistants/harness.test.js +372 -12
  89. package/server/lib/assistants/routes.js +3 -0
  90. package/server/lib/assistants/service.js +1079 -38
  91. package/server/lib/assistants/service.test.js +1235 -26
  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-DOZPK9oh.js +0 -2
  99. package/dist/assets/MarkstreamRendererImpl-D4nUXYpP.js +0 -3
  100. package/dist/assets/MobileDetailNavigation-Bfs4CTUh.js +0 -1
  101. package/dist/assets/MobileShareBridge-DtwVLkBL.js +0 -4
  102. package/dist/assets/MobileSurface-BmYYdY3g.js +0 -2
  103. package/dist/assets/insertionBoundaries-BBn8YW82.js +0 -4
  104. package/dist/assets/main-Cd7NwXx5.js +0 -169
  105. package/dist/assets/miniChat-D6UYe50H.js +0 -2
  106. package/dist/assets/mobile-w2Ed9I5O.js +0 -2
  107. package/dist/assets/platform-DzsGqlGW.js +0 -2
  108. package/dist/assets/renderMobileApp-BvIE86_O.js +0 -28
  109. package/dist/assets/runtimeEndpointReset-lF5S7U4I.js +0 -147
  110. package/dist/assets/sessionChangeSummary-DQDZ5HW-.js +0 -1
  111. package/dist/assets/sessionLookup-CNRDupYq.js +0 -1
  112. package/dist/assets/src-AA-5nXsF.css +0 -2
  113. package/dist/assets/useMobileNavigationStore-DANrWekj.js +0 -413
@@ -10,23 +10,29 @@ import { contactCardIdentity, parseContactCard, parseContactPart } from './cards
10
10
  import {
11
11
  CONTACT_PAGE_DEFAULT_LIMIT,
12
12
  CONTACT_PAGE_MAX_LIMIT,
13
- CONTACT_SETTLE_TEXT,
13
+ advanceContactReadWatermark,
14
14
  bumpContactGeneration,
15
15
  clearContactMemory as clearContactMemoryStore,
16
16
  contactHistoryForLlm,
17
17
  createActiveContactTurn,
18
18
  deleteContactMessages,
19
+ deleteContactReadState,
19
20
  ensureContactSchema,
20
21
  contactPartsFingerprint,
21
22
  getContactMessage,
23
+ getContactReadTip,
24
+ getContactReadWatermark,
25
+ getContactUnreadSnapshots,
22
26
  getLatestContactMessagePreview,
23
27
  getLatestContactMessagePreviews,
24
28
  insertContactMessage,
25
29
  listContactMessages,
26
30
  listInFlightWatches,
27
31
  listWatchesBySession,
32
+ migrateContactReadStateDefaultRead,
28
33
  nextContactOrdinal,
29
34
  projectActiveContactTurn,
35
+ resetContactReadWatermarkForGeneration,
30
36
  updateSessionCardStatus,
31
37
  upsertContactWatch,
32
38
  } from './contact-store.js';
@@ -64,8 +70,12 @@ import {
64
70
  ASSIGN_CODES,
65
71
  assignSession,
66
72
  attachmentScopeKey,
73
+ extractSessionDirectory,
74
+ extractSessionTitle,
67
75
  hasAssignImageParts,
76
+ mapSessionToWatchStatus,
68
77
  resolveAssignDirectory,
78
+ extractAssignSessionModel,
69
79
  resolveAssignWorkerModel,
70
80
  sanitizeAssignFileParts,
71
81
  } from './assign.js';
@@ -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();
@@ -128,6 +138,12 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
128
138
  const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.data !== undefined || result?.response?.ok === true);
129
139
  /** Bound catalog / project list waits so a contact lane cannot hang forever. */
130
140
  export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
141
+ /** Bound last worker assistant text injected into assigned-session resume prompts. */
142
+ export const ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX = 2_000;
143
+ /** Stable resume turn/message id for assigned-session complete/error continuation. */
144
+ export const assignedSessionResumeMessageID = (assistantID, sessionID, status, updatedAt) => (
145
+ `resume_${assistantID}_${sessionID}_${status}_${updatedAt}`
146
+ );
131
147
  const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code = 'upstream_error') => {
132
148
  let timer = null;
133
149
  try {
@@ -146,7 +162,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
146
162
  }
147
163
  };
148
164
 
149
- export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
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 } = {}) => {
150
166
  if (!dbPath || !dataDir) return null;
151
167
  const Database = require('better-sqlite3');
152
168
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -283,12 +299,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
283
299
  }
284
300
  activeContactTurnsByAssistant.delete(assistantID);
285
301
  };
286
- const output = (row, latestMessagePreview) => {
302
+ const output = (row, latestMessagePreview, unreadSnapshot) => {
287
303
  const watches = listInFlightWatches(db, row.assistant_id);
288
304
  const activeContactTurn = projectActiveContactTurn(activeContactTurnsFor(row.assistant_id));
289
305
  const preview = latestMessagePreview !== undefined
290
306
  ? latestMessagePreview
291
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
+ });
292
315
  return {
293
316
  id: row.assistant_id,
294
317
  revision: row.revision,
@@ -315,6 +338,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
315
338
  // Contact-transcript list desc: authoritative last visible bubble (not
316
339
  // defaultPrompt / updatedAt). null when the contact has no list-visible rows.
317
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,
318
347
  createdAt: row.created_at,
319
348
  updatedAt: row.updated_at,
320
349
  tombstoneAt: row.tombstone_at,
@@ -380,6 +409,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
380
409
  const legacy = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='assistant'").get();
381
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); } }
382
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() });
383
415
  db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
384
416
  };
385
417
  migrate();
@@ -454,8 +486,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
454
486
  const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
455
487
  return nonEmptyString(sessionID) ? sessionID : '';
456
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
+ };
457
502
  const settleStatusFromEvent = (payload, properties) => {
458
- if (payload.type === 'session.error') return 'error';
503
+ if (payload.type === 'session.error') return isUserAbort(properties.error) ? 'cancelled' : 'error';
459
504
  if (payload.type === 'question.asked' || payload.type === 'permission.asked') return 'question';
460
505
  if (payload.type === 'session.idle') return 'complete';
461
506
  if (payload.type === 'session.status') {
@@ -465,37 +510,40 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
465
510
  }
466
511
  return null;
467
512
  };
513
+ // Assigned-session complete/error resumes the contact LLM (no canned settle bubble).
514
+ // Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
515
+ const assignedSessionResumeRef = { schedule: null };
468
516
  const reportAssignedSession = (sessionID, status) => {
517
+ if (status === 'cancelled') cancelAssignedResumes({ sessionID });
469
518
  const watches = listWatchesBySession(db, sessionID);
470
519
  if (watches.length === 0) return false;
471
520
  let changed = false;
521
+ const resumes = [];
472
522
  db.exec('BEGIN IMMEDIATE');
473
523
  try {
474
524
  for (const watch of watches) {
475
- if (watch.status === status) continue;
525
+ if (watch.status === status || watch.status === 'cancelled') continue;
476
526
  // session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
477
527
  if (status === 'complete' && watch.status === 'error') continue;
528
+ const updatedAt = now();
478
529
  updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
479
530
  upsertContactWatch(db, {
480
531
  assistantID: watch.assistantID,
481
532
  sessionID,
482
533
  directory: watch.directory,
483
534
  status,
484
- updatedAt: now(),
535
+ updatedAt,
536
+ resumeAllowed: watch.resumeAllowed,
485
537
  });
486
- const settleText = CONTACT_SETTLE_TEXT[status];
487
- const settleID = `settle_${watch.assistantID}_${sessionID}_${status}`;
488
- if (settleText && !db.prepare('SELECT 1 AS ok FROM assistant_contact_message WHERE message_id=?').get(settleID)) {
489
- insertContactMessage(db, {
490
- messageID: settleID,
538
+ // complete/error: hand result back to contact LLM. question: card only (worker waiting).
539
+ // Never insert oc.settle.* canned transcript bubbles.
540
+ if (watch.resumeAllowed && (status === 'complete' || status === 'error')) {
541
+ resumes.push({
491
542
  assistantID: watch.assistantID,
492
- role: 'assistant',
493
- turnID: `settle:${sessionID}`,
494
- bubbleIndex: 0,
495
- createdAt: now(),
496
- ordinal: nextContactOrdinal(db, watch.assistantID),
497
- status: 'complete',
498
- parts: [{ type: 'text', text: settleText }],
543
+ sessionID,
544
+ status,
545
+ directory: watch.directory,
546
+ updatedAt,
499
547
  });
500
548
  }
501
549
  changed = true;
@@ -506,6 +554,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
506
554
  db.exec('ROLLBACK');
507
555
  throw error;
508
556
  }
557
+ // processEvent stays sync; resume runs on the contact turn lane asynchronously.
558
+ if (typeof assignedSessionResumeRef.schedule === 'function') {
559
+ for (const resume of resumes) assignedSessionResumeRef.schedule(resume);
560
+ }
509
561
  return changed;
510
562
  };
511
563
  const sessionStatusType = (session) => {
@@ -526,12 +578,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
526
578
  if (isMissing(getResult)) return 'complete';
527
579
  if (getResult?.error) return null;
528
580
  const session = getResult?.data;
529
- if (session?.error) return 'error';
581
+ if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
530
582
  const statusType = sessionStatusType(session);
531
583
  if (statusType === 'busy' || statusType === 'retry') return null;
532
584
  if (isMissing(messagesResult)) return 'complete';
533
585
  const assistant = messagesResult && !messagesResult.error ? lastAssistantInfo(messagesResult.data) : null;
534
- if (assistant?.error) return 'error';
586
+ if (assistant?.error) return isUserAbort(assistant.error) ? 'cancelled' : 'error';
535
587
  if (statusType === 'idle' || session?.time?.completed) return 'complete';
536
588
  if (assistant?.time?.completed) return 'complete';
537
589
  return null;
@@ -585,7 +637,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
585
637
  if (payload.type === 'message.updated') {
586
638
  const info = properties.info; const sessionID = info?.sessionID;
587
639
  if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
588
- 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;
589
643
  }
590
644
  if (payload.type === 'message.part.updated') {
591
645
  const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
@@ -994,6 +1048,23 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
994
1048
  throw wrapped;
995
1049
  }
996
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
+ };
997
1068
  const loadRegisteredProjects = async () => {
998
1069
  const listed = await awaitWithDeadline(listProjects(), CONTACT_CATALOG_DEADLINE_MS, 'upstream_error');
999
1070
  if (!Array.isArray(listed)) {
@@ -1050,6 +1121,267 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1050
1121
  return null;
1051
1122
  }
1052
1123
  };
1124
+ /**
1125
+ * Best-effort previous worker model for a reused sessionID.
1126
+ * session.get → session.model, else newest message info.model. Never throws / never fails assign.
1127
+ */
1128
+ const lookupReusedSessionModel = async ({ sessionID, directory, signal } = {}) => {
1129
+ if (typeof sessionID !== 'string' || !sessionID.trim()) return null;
1130
+ const api = client();
1131
+ const options = signal ? { signal } : undefined;
1132
+ const dir = typeof directory === 'string' && directory.trim() ? directory.trim() : null;
1133
+ try {
1134
+ const got = await api.session.get({
1135
+ sessionID,
1136
+ ...(dir ? { directory: dir } : {}),
1137
+ }, options);
1138
+ if (got?.error) return null;
1139
+ const sessionPayload = got?.data ?? got;
1140
+ let messages = null;
1141
+ const fromSession = extractAssignSessionModel({ session: sessionPayload });
1142
+ if (fromSession) return fromSession;
1143
+ try {
1144
+ if (typeof api.session.messages !== 'function') return null;
1145
+ const listed = await api.session.messages({
1146
+ sessionID,
1147
+ ...(dir ? { directory: dir } : {}),
1148
+ limit: 40,
1149
+ }, options);
1150
+ if (listed?.error) return null;
1151
+ messages = listed?.data ?? listed;
1152
+ } catch {
1153
+ return null;
1154
+ }
1155
+ return extractAssignSessionModel({ session: sessionPayload, messages });
1156
+ } catch {
1157
+ return null;
1158
+ }
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
+ };
1053
1385
  const assignWork = async (row, params = {}) => {
1054
1386
  const fileParts = sanitizeAssignFileParts(params.fileParts || params.parts || []);
1055
1387
  const signal = params.signal;
@@ -1060,16 +1392,63 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1060
1392
  || (typeof params.model === 'string' && params.model.trim())
1061
1393
  || (params.model && typeof params.model === 'object'),
1062
1394
  );
1063
- const needsCatalog = explicitModel || hasAssignImageParts(fileParts);
1395
+ const reuseSessionID = typeof params.sessionID === 'string' && params.sessionID.trim()
1396
+ ? params.sessionID.trim()
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
+ }
1416
+ // Reused session without explicit model may follow the session's last model (catalog-gated).
1417
+ const followSessionModel = Boolean(reuseSessionID && !explicitModel);
1418
+ const needsCatalog = explicitModel || hasAssignImageParts(fileParts) || followSessionModel;
1064
1419
  let catalog = null;
1420
+ let sessionModel = null;
1065
1421
  if (needsCatalog) {
1066
- catalog = await loadAssignCatalog(signal);
1422
+ if (followSessionModel && !explicitModel && !hasAssignImageParts(fileParts)) {
1423
+ // Session-follow degrades on catalog failure; explicit/image stay fail-closed.
1424
+ try {
1425
+ catalog = await loadAssignCatalog(signal);
1426
+ } catch {
1427
+ catalog = null;
1428
+ }
1429
+ } else {
1430
+ catalog = await loadAssignCatalog(signal);
1431
+ }
1432
+ }
1433
+ if (followSessionModel && catalog && reuseScope) {
1434
+ sessionModel = extractAssignSessionModel({
1435
+ session: reuseScope.session,
1436
+ messages: reuseScope.messages,
1437
+ });
1438
+ if (!sessionModel) {
1439
+ sessionModel = await lookupReusedSessionModel({
1440
+ sessionID: reuseSessionID,
1441
+ directory: reuseScope.directory,
1442
+ signal,
1443
+ });
1444
+ }
1067
1445
  }
1068
1446
  const workerModel = resolveAssignWorkerModel({
1069
1447
  providerID: params.providerID,
1070
1448
  modelID: params.modelID,
1071
1449
  model: params.model,
1072
1450
  fallback: { providerID: row.provider_id, modelID: row.model_id },
1451
+ sessionModel,
1073
1452
  catalog,
1074
1453
  });
1075
1454
  let acceptsImages = workerModel.acceptsImages;
@@ -1083,6 +1462,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1083
1462
  // Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
1084
1463
  return assignSession({
1085
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
+ } : {}),
1086
1475
  fileParts,
1087
1476
  ...(params.variant !== undefined ? { variant: params.variant } : {}),
1088
1477
  ...(signal ? { signal } : {}),
@@ -1109,6 +1498,112 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1109
1498
  messageID: params?.messageID || `msg_assign_${id()}`,
1110
1499
  });
1111
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
+ };
1112
1607
  const matchRegisteredProject = async (directory) => {
1113
1608
  const projects = await listProjects();
1114
1609
  const resolved = path.resolve(directory);
@@ -1338,6 +1833,401 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1338
1833
  }),
1339
1834
  };
1340
1835
  };
1836
+ const findAssignedSessionCardTitle = (assistantID, sessionID) => {
1837
+ const rows = db.prepare(
1838
+ 'SELECT p.part_json FROM assistant_contact_part p JOIN assistant_contact_message m ON m.message_id=p.message_id WHERE m.assistant_id=?',
1839
+ ).all(assistantID);
1840
+ for (const row of rows) {
1841
+ let part;
1842
+ try { part = parseContactPart(parse(row.part_json)); } catch { continue; }
1843
+ if (part?.type === 'card' && part.cardType === 'session' && part.sessionID === sessionID) {
1844
+ return typeof part.title === 'string' && part.title.trim() ? part.title.trim() : null;
1845
+ }
1846
+ }
1847
+ return null;
1848
+ };
1849
+ const extractLastWorkerAssistantText = (messages, maxChars = ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX) => {
1850
+ if (!Array.isArray(messages)) return '';
1851
+ const limit = Number.isFinite(maxChars) && maxChars > 0 ? Math.floor(maxChars) : ASSIGNED_SESSION_RESUME_WORKER_TEXT_MAX;
1852
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
1853
+ const entry = messages[index];
1854
+ const info = entry?.info ?? entry;
1855
+ if (info?.role !== 'assistant') continue;
1856
+ const parts = Array.isArray(entry?.parts)
1857
+ ? entry.parts
1858
+ : (Array.isArray(info?.parts) ? info.parts : []);
1859
+ const chunks = [];
1860
+ for (const part of parts) {
1861
+ if (part?.type === 'text' && typeof part.text === 'string' && part.text.trim()) {
1862
+ chunks.push(part.text.trim());
1863
+ }
1864
+ }
1865
+ if (chunks.length === 0 && typeof info?.content === 'string' && info.content.trim()) {
1866
+ chunks.push(info.content.trim());
1867
+ }
1868
+ if (chunks.length === 0) continue;
1869
+ const joined = chunks.join('\n');
1870
+ return joined.length > limit ? joined.slice(0, limit) : joined;
1871
+ }
1872
+ return '';
1873
+ };
1874
+ const buildAssignedSessionResumeUserText = ({ sessionID, status, title, workerText }) => {
1875
+ const lines = [
1876
+ '[Internal assigned-session resume — not a user message. Do not quote this block.]',
1877
+ `sessionID: ${sessionID}`,
1878
+ `status: ${status}`,
1879
+ ];
1880
+ if (title) lines.push(`cardTitle: ${title}`);
1881
+ if (workerText) {
1882
+ lines.push('lastWorkerAssistantText (bounded):');
1883
+ lines.push(workerText);
1884
+ } else {
1885
+ lines.push('lastWorkerAssistantText: (unavailable)');
1886
+ }
1887
+ lines.push(
1888
+ 'Instructions:',
1889
+ '- Never tell the user canned phrases like "会话已完成" / "Session finished" / "会话失败" / "Session failed".',
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.',
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.',
1893
+ );
1894
+ return lines.join('\n');
1895
+ };
1896
+ /**
1897
+ * After assigned worker complete/error: async contact-lane continuation.
1898
+ * No user transcript row — only internal runContactTurn userText + assistant bubbles/cards.
1899
+ */
1900
+ const scheduleAssignedSessionResume = ({ assistantID, sessionID, status, directory, updatedAt }) => {
1901
+ if (closed) return;
1902
+ if (status !== 'complete' && status !== 'error') return;
1903
+ const row = assistant(assistantID);
1904
+ if (!row || row.tombstone_at) return;
1905
+ const messageID = assignedSessionResumeMessageID(assistantID, sessionID, status, updatedAt);
1906
+ const turnID = messageID;
1907
+ // Idempotent: same watch status already resumed (bubbles or durable error).
1908
+ if (
1909
+ getContactMessage(db, `${messageID}:bubble:1`)
1910
+ || getContactMessage(db, `${messageID}:error`)
1911
+ ) return;
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
+
1922
+ rememberActiveContactTurn(assistantID, {
1923
+ turnID,
1924
+ messageID,
1925
+ status: 'queued',
1926
+ admittedAt: now(),
1927
+ });
1928
+ // Tip snapshot so list green dots / 3-dot row rehydrate without waiting for lane start.
1929
+ bump();
1930
+ emitContactTurnEvent('openchamber:contact-turn-start', {
1931
+ assistantID,
1932
+ turnID,
1933
+ messageID,
1934
+ });
1935
+ contactTurnSettlement(messageID);
1936
+
1937
+ void inContactTurnLane(assistantID, async () => {
1938
+ if (controller.signal.aborted) { finishCancelled(); return; }
1939
+ if (closed) {
1940
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1941
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
1942
+ return;
1943
+ }
1944
+ const live = assistant(assistantID);
1945
+ if (!live || live.tombstone_at) {
1946
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1947
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'not_found' });
1948
+ return;
1949
+ }
1950
+ if (
1951
+ getContactMessage(db, `${messageID}:bubble:1`)
1952
+ || getContactMessage(db, `${messageID}:error`)
1953
+ ) {
1954
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
1955
+ resolveContactTurnSettlement(messageID, { status: 'complete', replayed: true });
1956
+ return;
1957
+ }
1958
+ markActiveContactTurnRunning(assistantID, turnID);
1959
+ const assistantSnapshot = output(live);
1960
+ let registeredProjects = [];
1961
+ try {
1962
+ registeredProjects = filterRegisteredProjects(await loadRegisteredProjects());
1963
+ } catch {
1964
+ registeredProjects = [];
1965
+ }
1966
+ const modelContext = await loadContactModelContext(controller.signal);
1967
+
1968
+ let workerText = '';
1969
+ try {
1970
+ const watchDirectory = nonEmptyString(directory)
1971
+ ? (() => {
1972
+ try { return workspace(directory, assistantID); } catch { return null; }
1973
+ })()
1974
+ : null;
1975
+ const resolvedDirectory = watchDirectory || (() => {
1976
+ try { return effectiveWorkspace(live); } catch { return null; }
1977
+ })();
1978
+ if (resolvedDirectory) {
1979
+ const messagesResult = await client().session.messages({
1980
+ sessionID,
1981
+ directory: resolvedDirectory,
1982
+ limit: 100,
1983
+ });
1984
+ if (!messagesResult?.error) {
1985
+ const rows = Array.isArray(messagesResult?.data)
1986
+ ? messagesResult.data
1987
+ : (Array.isArray(messagesResult) ? messagesResult : []);
1988
+ if (isUserAbort(lastAssistantInfo(rows)?.error)) {
1989
+ reportAssignedSession(sessionID, 'cancelled');
1990
+ finishCancelled();
1991
+ return;
1992
+ }
1993
+ workerText = extractLastWorkerAssistantText(rows);
1994
+ }
1995
+ }
1996
+ } catch {
1997
+ workerText = '';
1998
+ }
1999
+
2000
+ if (controller.signal.aborted) { finishCancelled(); return; }
2001
+ const title = findAssignedSessionCardTitle(assistantID, sessionID);
2002
+ const userText = buildAssignedSessionResumeUserText({
2003
+ sessionID,
2004
+ status,
2005
+ title,
2006
+ workerText,
2007
+ });
2008
+ const history = contactHistoryForLlm(db, assistantID, {});
2009
+ const assignedCards = [];
2010
+ let contactResetThisTurn = false;
2011
+ let contactHistoryClearedThisTurn = false;
2012
+
2013
+ try {
2014
+ const executionHistory = await materializeContactHistory(
2015
+ db,
2016
+ dataDir,
2017
+ assistantID,
2018
+ history,
2019
+ { budgetBytes: CONTACT_ATTACHMENT_TURN_BUDGET_BYTES },
2020
+ );
2021
+ const tools = createContactTools({
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),
2028
+ createAssistant: (toolInput) => createAssistant(toolInput),
2029
+ scheduleTask: (params) => scheduleWork(live, params),
2030
+ deliverPeerMessage: (toolInput) => deliverPeerMessage(assistantID, toolInput),
2031
+ clearContactMemory: () => {
2032
+ const result = clearContactMemory(assistantID, {});
2033
+ contactResetThisTurn = true;
2034
+ return result;
2035
+ },
2036
+ resetContact: () => {
2037
+ const result = resetContact(assistantID, {});
2038
+ contactResetThisTurn = true;
2039
+ contactHistoryClearedThisTurn = true;
2040
+ return result;
2041
+ },
2042
+ listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
2043
+ listProjects: async () => loadRegisteredProjects(),
2044
+ listSessions: (params) => listSessionsWork(params),
2045
+ readSession: (params) => readSessionWork(params),
2046
+ readAssistantSettings: (input = {}) => {
2047
+ const targetID = typeof input?.assistantID === 'string' && input.assistantID.trim()
2048
+ ? input.assistantID.trim()
2049
+ : assistantID;
2050
+ return output(editable(targetID));
2051
+ },
2052
+ updateAssistantSettings: async (patch = {}) => {
2053
+ const normalizePrompt = (value) => {
2054
+ if (typeof value !== 'string') fail('validation_error');
2055
+ if (value.length > 200_000) fail('validation_error');
2056
+ return value.trim();
2057
+ };
2058
+ const targetID = typeof patch?.assistantID === 'string' && patch.assistantID.trim()
2059
+ ? patch.assistantID.trim()
2060
+ : assistantID;
2061
+ let lastError = null;
2062
+ for (let attempt = 0; attempt < 2; attempt++) {
2063
+ const current = output(editable(targetID));
2064
+ if (!Object.prototype.hasOwnProperty.call(patch, 'defaultPrompt')) {
2065
+ fail('validation_error');
2066
+ }
2067
+ const nextPrompt = normalizePrompt(patch.defaultPrompt);
2068
+ if (current.defaultPrompt === nextPrompt) {
2069
+ return {
2070
+ updated: false,
2071
+ unchanged: true,
2072
+ defaultPrompt: current.defaultPrompt,
2073
+ assistant: current,
2074
+ };
2075
+ }
2076
+ await Promise.resolve();
2077
+ try {
2078
+ const updated = await updateAssistant(targetID, {
2079
+ expectedRevision: current.revision,
2080
+ defaultPrompt: nextPrompt,
2081
+ });
2082
+ return {
2083
+ updated: true,
2084
+ defaultPrompt: updated.defaultPrompt,
2085
+ assistant: updated,
2086
+ };
2087
+ } catch (error) {
2088
+ lastError = error;
2089
+ if (error?.code !== 'revision_conflict') throw error;
2090
+ }
2091
+ }
2092
+ throw lastError;
2093
+ },
2094
+ currentAssistant: assistantSnapshot,
2095
+ onCard: (card) => assignedCards.push(card),
2096
+ turnFileParts: [],
2097
+ turnAttachmentScope: attachmentScopeKey([]),
2098
+ });
2099
+ controller.signal.throwIfAborted();
2100
+ const generated = await runContactTurn({
2101
+ signal: controller.signal,
2102
+ readOnly: true,
2103
+ assistant: assistantSnapshot,
2104
+ history: executionHistory,
2105
+ userText,
2106
+ userParts: [{ type: 'text', text: userText }],
2107
+ language: contactTurnLanguages.get(assistantID) ?? '',
2108
+ createChatCompletion,
2109
+ tools,
2110
+ projects: registeredProjects,
2111
+ ...modelContext,
2112
+ globalEventHub,
2113
+ onBubbleDelta: (bubbleIndex, delta, done) => {
2114
+ if (controller.signal.aborted) return;
2115
+ emitContactTurnEvent('openchamber:contact-bubble-delta', {
2116
+ assistantID,
2117
+ turnID,
2118
+ bubbleIndex,
2119
+ delta: typeof delta === 'string' ? delta : '',
2120
+ done: Boolean(done),
2121
+ });
2122
+ },
2123
+ });
2124
+ if (controller.signal.aborted) { finishCancelled(); return; }
2125
+ if (closed) {
2126
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
2127
+ resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
2128
+ return;
2129
+ }
2130
+ const resetThisTurn = contactResetThisTurn || generated?.reset === true;
2131
+ const historyClearedThisTurn = contactHistoryClearedThisTurn
2132
+ || contactTurnClearedChatHistory(generated?.messages)
2133
+ || generated?.historyCleared === true;
2134
+ const preferredConfirm = historyClearedThisTurn
2135
+ ? CLEAR_CHAT_HISTORY_CONFIRM_BUBBLE
2136
+ : NEW_CONVERSATION_CONFIRM_BUBBLE;
2137
+ const bubbles = resetThisTurn
2138
+ ? confirmBubbleAfterContactReset(generated?.bubbles, preferredConfirm)
2139
+ : (Array.isArray(generated?.bubbles) ? generated.bubbles.filter((item) => typeof item === 'string' && item.trim()) : []);
2140
+ const cards = resetThisTurn
2141
+ ? []
2142
+ : [
2143
+ ...assignedCards,
2144
+ ...(Array.isArray(generated?.cards) ? generated.cards : []),
2145
+ ].filter((card, index, list) => list.findIndex((item) => contactCardIdentity(item) === contactCardIdentity(card)) === index);
2146
+ if (bubbles.length === 0 && cards.length === 0) {
2147
+ const detail = 'Assistant returned no text';
2148
+ try {
2149
+ persistContactTurnFailure(assistantID, {
2150
+ userMessageID: messageID,
2151
+ turnID,
2152
+ error: detail,
2153
+ });
2154
+ settleActiveContactTurn(assistantID, turnID);
2155
+ } catch {
2156
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
2157
+ }
2158
+ emitContactTurnEvent('openchamber:contact-turn-end', {
2159
+ assistantID,
2160
+ turnID,
2161
+ status: 'error',
2162
+ error: detail,
2163
+ });
2164
+ notifyContactTurn({
2165
+ assistantID,
2166
+ name: assistantSnapshot.name,
2167
+ turnID,
2168
+ status: 'error',
2169
+ body: detail,
2170
+ });
2171
+ resolveContactTurnSettlement(messageID, { status: 'error', error: detail });
2172
+ return;
2173
+ }
2174
+ // No user row for resume — only assistant bubbles/cards (userMessageID is id prefix).
2175
+ persistContactAssistantReply(assistantID, {
2176
+ userMessageID: messageID,
2177
+ bubbles: bubbles.length > 0 ? bubbles : [ASSIGNED_SESSION_FALLBACK_BUBBLE],
2178
+ cards,
2179
+ turnID,
2180
+ });
2181
+ settleActiveContactTurn(assistantID, turnID);
2182
+ emitContactTurnEvent('openchamber:contact-turn-end', {
2183
+ assistantID,
2184
+ turnID,
2185
+ status: 'complete',
2186
+ });
2187
+ const spoken = bubbles.filter((text) => typeof text === 'string' && text.trim() && !text.startsWith('oc.settle.'));
2188
+ notifyContactTurn({
2189
+ assistantID,
2190
+ name: assistantSnapshot.name,
2191
+ turnID,
2192
+ status: 'complete',
2193
+ body: spoken.join('\n') || cards[0]?.title || '',
2194
+ });
2195
+ resolveContactTurnSettlement(messageID, { status: 'complete' });
2196
+ } catch (error) {
2197
+ if (controller.signal.aborted) { finishCancelled(); return; }
2198
+ const detail = typeof error?.message === 'string' && error.message.trim()
2199
+ ? error.message.trim()
2200
+ : (error?.code || 'upstream_error');
2201
+ const statusCode = error?.code === 'no_provider' ? 'no_provider' : (error?.code || 'upstream_error');
2202
+ try {
2203
+ persistContactTurnFailure(assistantID, {
2204
+ userMessageID: messageID,
2205
+ turnID,
2206
+ error: detail,
2207
+ });
2208
+ settleActiveContactTurn(assistantID, turnID);
2209
+ } catch {
2210
+ settleActiveContactTurn(assistantID, turnID, { bumpRevision: true });
2211
+ }
2212
+ emitContactTurnEvent('openchamber:contact-turn-end', {
2213
+ assistantID,
2214
+ turnID,
2215
+ status: 'error',
2216
+ error: detail,
2217
+ ...(statusCode !== detail ? { code: statusCode } : {}),
2218
+ });
2219
+ notifyContactTurn({
2220
+ assistantID,
2221
+ name: assistantSnapshot.name,
2222
+ turnID,
2223
+ status: 'error',
2224
+ body: detail,
2225
+ });
2226
+ resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
2227
+ }
2228
+ }).finally(() => { assignedResumes.delete(messageID); });
2229
+ };
2230
+ assignedSessionResumeRef.schedule = scheduleAssignedSessionResume;
1341
2231
  /**
1342
2232
  * Contact composer send: validate → admit user (202) → async turn.
1343
2233
  * Does not await the LLM. Live bubble tokens use openchamber:contact-bubble-delta
@@ -1349,7 +2239,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1349
2239
  const messageID = string(input.messageID, 256, true);
1350
2240
  const userText = extractUserText(input);
1351
2241
  if (!userText) fail('validation_error');
2242
+ const language = typeof input.language === 'string' ? input.language.trim().slice(0, 64) : '';
1352
2243
  const row = active(assistantID);
2244
+ if (language) contactTurnLanguages.set(row.assistant_id, language);
1353
2245
  const userParts = await userContactParts(row.assistant_id, input, userText);
1354
2246
  if (typeof createChatCompletion !== 'function' && runContactTurn === defaultRunContactTurn) fail('upstream_error');
1355
2247
  let registeredProjects = [];
@@ -1379,12 +2271,24 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1379
2271
  replayed: true,
1380
2272
  };
1381
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);
1382
2278
  admitContactUser(row.assistant_id, {
1383
2279
  userMessageID: messageID,
1384
2280
  userText,
1385
2281
  userParts,
1386
2282
  turnID,
1387
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
+ };
1388
2292
  rememberActiveContactTurn(row.assistant_id, {
1389
2293
  turnID,
1390
2294
  messageID,
@@ -1399,6 +2303,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1399
2303
  contactTurnSettlement(messageID);
1400
2304
  const kickTurn = () => {
1401
2305
  void inContactTurnLane(row.assistant_id, async () => {
2306
+ if (controller.signal.aborted) { finishCancelled(); return; }
1402
2307
  if (closed) {
1403
2308
  settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1404
2309
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1421,15 +2326,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1421
2326
  const assignedCards = [];
1422
2327
  let contactResetThisTurn = false;
1423
2328
  let contactHistoryClearedThisTurn = false;
1424
- let connectedModels = [];
1425
- try {
1426
- const catalog = await loadAssignCatalog(undefined);
1427
- connectedModels = normalizeConnectedModels(catalog?.models);
1428
- } catch {
1429
- // Catalog failure must not block default-model contact turns.
1430
- // Explicit worker model / image capability checks still fail closed inside assignWork.
1431
- connectedModels = [];
1432
- }
2329
+ const modelContext = await loadContactModelContext(controller.signal);
1433
2330
  try {
1434
2331
  // Materialize inside turn try so failures durable-error + settle working.
1435
2332
  // DB/UI keep descriptors; harness + assign get execution-time data URLs under budget.
@@ -1454,6 +2351,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1454
2351
  const turnAttachmentScope = attachmentScopeKey(turnFileParts);
1455
2352
  const tools = createContactTools({
1456
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),
1457
2359
  createAssistant: (toolInput) => createAssistant(toolInput),
1458
2360
  scheduleTask: (params) => scheduleWork(row, params),
1459
2361
  deliverPeerMessage: (toolInput) => deliverPeerMessage(row.assistant_id, toolInput),
@@ -1479,22 +2381,79 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1479
2381
  listAssistants: () => db.prepare('SELECT * FROM assistant_v2 WHERE tombstone_at IS NULL ORDER BY created_at').all().map(output),
1480
2382
  listProjects: async () => loadRegisteredProjects(),
1481
2383
  listSessions: (params) => listSessionsWork(params),
2384
+ readSession: (params) => readSessionWork(params),
2385
+ // Live DB read — not the turn-start assistantSnapshot.
2386
+ // Optional assistantID targets another live row; omitted → this contact.
2387
+ readAssistantSettings: (input = {}) => {
2388
+ const assistantID = typeof input?.assistantID === 'string' && input.assistantID.trim()
2389
+ ? input.assistantID.trim()
2390
+ : row.assistant_id;
2391
+ return output(editable(assistantID));
2392
+ },
2393
+ // Persist defaultPrompt via updateAssistant CAS; one revision_conflict retry.
2394
+ updateAssistantSettings: async (patch = {}) => {
2395
+ const normalizePrompt = (value) => {
2396
+ if (typeof value !== 'string') fail('validation_error');
2397
+ if (value.length > 200_000) fail('validation_error');
2398
+ return value.trim();
2399
+ };
2400
+ const assistantID = typeof patch?.assistantID === 'string' && patch.assistantID.trim()
2401
+ ? patch.assistantID.trim()
2402
+ : row.assistant_id;
2403
+ let lastError = null;
2404
+ for (let attempt = 0; attempt < 2; attempt++) {
2405
+ const current = output(editable(assistantID));
2406
+ if (!Object.prototype.hasOwnProperty.call(patch, 'defaultPrompt')) {
2407
+ fail('validation_error');
2408
+ }
2409
+ const nextPrompt = normalizePrompt(patch.defaultPrompt);
2410
+ if (current.defaultPrompt === nextPrompt) {
2411
+ return {
2412
+ updated: false,
2413
+ unchanged: true,
2414
+ defaultPrompt: current.defaultPrompt,
2415
+ assistant: current,
2416
+ };
2417
+ }
2418
+ // Yield so a concurrent UI PATCH can land before CAS (and tests can inject).
2419
+ await Promise.resolve();
2420
+ try {
2421
+ const updated = await updateAssistant(assistantID, {
2422
+ expectedRevision: current.revision,
2423
+ defaultPrompt: nextPrompt,
2424
+ });
2425
+ return {
2426
+ updated: true,
2427
+ defaultPrompt: updated.defaultPrompt,
2428
+ assistant: updated,
2429
+ };
2430
+ } catch (error) {
2431
+ lastError = error;
2432
+ if (error?.code !== 'revision_conflict') throw error;
2433
+ }
2434
+ }
2435
+ throw lastError;
2436
+ },
1482
2437
  currentAssistant: assistantSnapshot,
1483
2438
  onCard: (card) => assignedCards.push(card),
1484
2439
  turnFileParts,
1485
2440
  turnAttachmentScope,
1486
2441
  });
2442
+ controller.signal.throwIfAborted();
1487
2443
  const generated = await runContactTurn({
2444
+ signal: controller.signal,
1488
2445
  assistant: assistantSnapshot,
1489
2446
  history: executionHistory,
1490
2447
  userText,
1491
2448
  userParts: executionParts,
2449
+ language,
1492
2450
  createChatCompletion,
1493
2451
  tools,
1494
2452
  projects: registeredProjects,
1495
- connectedModels,
2453
+ ...modelContext,
1496
2454
  globalEventHub,
1497
2455
  onBubbleDelta: (bubbleIndex, delta, done) => {
2456
+ if (controller.signal.aborted) return;
1498
2457
  emitContactTurnEvent('openchamber:contact-bubble-delta', {
1499
2458
  assistantID: row.assistant_id,
1500
2459
  turnID,
@@ -1504,6 +2463,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1504
2463
  });
1505
2464
  },
1506
2465
  });
2466
+ if (controller.signal.aborted) { finishCancelled(); return; }
1507
2467
  if (closed) {
1508
2468
  settleActiveContactTurn(row.assistant_id, turnID, { bumpRevision: true });
1509
2469
  resolveContactTurnSettlement(messageID, { status: 'error', error: 'closed' });
@@ -1590,6 +2550,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1590
2550
  });
1591
2551
  resolveContactTurnSettlement(messageID, { status: 'complete' });
1592
2552
  } catch (error) {
2553
+ if (controller.signal.aborted) { finishCancelled(); return; }
1593
2554
  const detail = typeof error?.message === 'string' && error.message.trim()
1594
2555
  ? error.message.trim()
1595
2556
  : (error?.code || 'upstream_error');
@@ -1619,6 +2580,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1619
2580
  body: detail,
1620
2581
  });
1621
2582
  resolveContactTurnSettlement(messageID, { status: 'error', error: detail, code: statusCode });
2583
+ } finally {
2584
+ contactControllers.delete(messageID);
1622
2585
  }
1623
2586
  });
1624
2587
  };
@@ -1698,6 +2661,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1698
2661
  deleteContactMessages(db, row.assistant_id, {
1699
2662
  ...(upToOrdinal !== undefined ? { upToOrdinal } : {}),
1700
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() });
1701
2667
  // Active contact turns stay until settle — wipe must not drop the green
1702
2668
  // dot while the clearing turn is still running.
1703
2669
  bump();
@@ -1708,6 +2674,48 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1708
2674
  throw error;
1709
2675
  }
1710
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
+ };
1711
2719
  const appendContactCard = (assistantID, input) => {
1712
2720
  const row = active(assistantID);
1713
2721
  const card = parseContactCard({ type: 'card', ...input, cardType: input?.cardType || 'session' });
@@ -1819,7 +2827,28 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1819
2827
  };
1820
2828
  return mode === 'stateless' ? inStatelessLane(deliveryTarget.assistantID, submit) : submit();
1821
2829
  };
1822
- 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
+ };
1823
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)); };
1824
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 }; };
1825
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; } };
@@ -1886,12 +2915,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1886
2915
  });
1887
2916
  const snapshot = () => {
1888
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);
1889
2919
  // Per-assistant indexed LIMIT probes + one parts batch (catalog ≤100).
1890
- 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);
1891
2923
  return {
1892
2924
  revision: revision(),
1893
2925
  enabled: enabled(),
1894
- 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
+ )),
1895
2931
  };
1896
2932
  };
1897
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) => {
@@ -1905,7 +2941,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1905
2941
  db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=?').run(assistantID);
1906
2942
  db.prepare('DELETE FROM assistant_scheduled_task WHERE assistant_id=?').run(assistantID);
1907
2943
  deleteContactMessages(db, assistantID);
2944
+ deleteContactReadState(db, assistantID);
1908
2945
  clearActiveContactTurns(assistantID);
2946
+ contactTurnLanguages.delete(assistantID);
1909
2947
  bump();
1910
2948
  db.exec('COMMIT');
1911
2949
  return { assistantID, tombstoneAt: now() };
@@ -1913,11 +2951,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
1913
2951
  db.exec('ROLLBACK');
1914
2952
  throw error;
1915
2953
  }
1916
- }, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
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();
1917
2957
  if (!closed) {
1918
2958
  closed = true;
1919
2959
  try { contactAttachmentMigration.controller.abort(); } catch { /* ignore */ }
1920
2960
  clearActiveContactTurns();
2961
+ contactTurnLanguages.clear();
1921
2962
  unsubscribeEvents?.();
1922
2963
  clearIntervalFn(timer);
1923
2964
  db.close();