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