@gotcos/glasses-server 6.9.0 → 6.11.0

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.
@@ -51,6 +51,7 @@ import {
51
51
  } from './codex-run-ledger.js'
52
52
  import { codexActivityPreviewLines } from './activity-preview.js'
53
53
  import {
54
+ collectRunOutputImagesBounded,
54
55
  createRunOutputImagePublisher,
55
56
  isRunOutputImagePublisherCommand,
56
57
  type RunOutputImageCollectionStats,
@@ -283,7 +284,12 @@ export async function callCodexStreaming(
283
284
  }
284
285
  }
285
286
  let codexThreadId: string | undefined = engineSession?.codexThreadId
286
- callbacks.onStart?.(model, sid, undefined, { codexRunId: run.runId, codexThreadId })
287
+ callbacks.onStart?.(model, sid, undefined, {
288
+ codexRunId: run.runId,
289
+ codexThreadId,
290
+ clientJobId: options?.clientJobId,
291
+ generation: options?.generation,
292
+ })
287
293
 
288
294
  let phase: Phase = 'context'
289
295
  let systemPrompt: string
@@ -312,7 +318,18 @@ export async function callCodexStreaming(
312
318
  const isFirstQuery = isNewSession(sid)
313
319
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
314
320
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
315
- const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
321
+ const exchangeProvenance = {
322
+ clientJobId: options?.clientJobId,
323
+ generation: options?.generation,
324
+ }
325
+ const pendingUserExchange = addExchange(
326
+ sid,
327
+ 'user',
328
+ historyQuery,
329
+ globalMsgNum,
330
+ undefined,
331
+ exchangeProvenance,
332
+ )
316
333
 
317
334
  let fullQuery: string
318
335
  if (imagePaths.length === 1) {
@@ -363,6 +380,41 @@ export async function callCodexStreaming(
363
380
  options?.abortSignal?.removeEventListener('abort', handleAbort)
364
381
  }
365
382
 
383
+ function clearEngineSessionBestEffort(reason: string) {
384
+ if (!engineSession) return
385
+ try {
386
+ clearCodexEngineSession(sid, model)
387
+ } catch (error) {
388
+ console.error(`[codex-bridge] engine session clear failed (${reason}):`, error)
389
+ }
390
+ }
391
+
392
+ function saveEngineSessionBestEffort() {
393
+ if (!persistentCodexSession || !codexThreadId) return
394
+ try {
395
+ const saved = saveCodexEngineSession({
396
+ cosSessionId: sid,
397
+ model,
398
+ codexThreadId,
399
+ cwd: codexCwd,
400
+ trustMode: codexTrustMode,
401
+ })
402
+ updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
403
+ } catch (error) {
404
+ // The resumable-thread cache is an optimization. Its filesystem failure
405
+ // must never suppress the durable query terminal callback.
406
+ console.error('[codex-bridge] engine session save failed:', error)
407
+ }
408
+ }
409
+
410
+ function finishRunBestEffort(input: Parameters<typeof finishCodexRun>[1]) {
411
+ try {
412
+ finishCodexRun(run.runId, input)
413
+ } catch (error) {
414
+ console.error('[codex-bridge] run ledger finalization failed:', error)
415
+ }
416
+ }
417
+
366
418
  function emitText(text: string) {
367
419
  if (!text || emittedBlocks.has(text)) return
368
420
  emittedBlocks.add(text)
@@ -377,7 +429,49 @@ export async function callCodexStreaming(
377
429
  cleanup()
378
430
  cleanupImages()
379
431
 
380
- const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
432
+ // The coordinator persists the final provider text before conversation
433
+ // mutation, condensation, or output-image normalization can stall/crash.
434
+ try {
435
+ const answerOwned = await callbacks.onAnswerReady?.(text)
436
+ if (answerOwned === false) {
437
+ outputImagePublisher?.cleanup()
438
+ removeExchange(sid, pendingUserExchange)
439
+ clearEngineSessionBestEffort('answer_ownership_lost')
440
+ finishRunBestEffort({
441
+ status: 'failed',
442
+ startedAtMs: startTime,
443
+ error: 'codex-bridge: durable answer ownership was lost.',
444
+ exitCode: null,
445
+ })
446
+ return
447
+ }
448
+ } catch (error) {
449
+ console.error('[codex-bridge] durable answer barrier failed:', error)
450
+ outputImagePublisher?.cleanup()
451
+ removeExchange(sid, pendingUserExchange)
452
+ clearEngineSessionBestEffort('answer_barrier')
453
+ finishRunBestEffort({
454
+ status: 'failed',
455
+ startedAtMs: startTime,
456
+ error: 'codex-bridge: durable answer persistence failed.',
457
+ exitCode: null,
458
+ })
459
+ try {
460
+ await callbacks.onError('codex-bridge: durable answer persistence failed.')
461
+ } catch (callbackError) {
462
+ console.error('[codex-bridge] durable barrier error callback failed:', callbackError)
463
+ }
464
+ return
465
+ }
466
+
467
+ const assistantExchange = addExchange(
468
+ sid,
469
+ 'assistant',
470
+ text,
471
+ globalMsgNum,
472
+ undefined,
473
+ exchangeProvenance,
474
+ )
381
475
  if (imagePaths.length > 0) {
382
476
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
383
477
  }
@@ -389,7 +483,9 @@ export async function callCodexStreaming(
389
483
  const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
390
484
  preparingHeartbeat.unref?.()
391
485
  try {
392
- outputAttachments = await outputImagePublisher.collect()
486
+ outputAttachments = await collectRunOutputImagesBounded(outputImagePublisher, {
487
+ signal: options?.abortSignal,
488
+ })
393
489
  } catch (err) {
394
490
  console.error('[codex-bridge] output image collection failed:', err)
395
491
  } finally {
@@ -416,29 +512,27 @@ export async function callCodexStreaming(
416
512
  durationMs: totalMs,
417
513
  caller: options?.lightweight ? 'voice_query' : 'full_query',
418
514
  })
419
- if (persistentCodexSession && codexThreadId) {
420
- const saved = saveCodexEngineSession({
421
- cosSessionId: sid,
422
- model,
423
- codexThreadId,
424
- cwd: codexCwd,
425
- trustMode: codexTrustMode,
426
- })
427
- updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
428
- }
515
+ saveEngineSessionBestEffort()
429
516
 
430
- finishCodexRun(run.runId, {
517
+ finishRunBestEffort({
431
518
  status: 'completed',
432
519
  startedAtMs: startTime,
433
520
  output: text,
434
521
  exitCode: 0,
435
522
  })
436
- callbacks.onDone(text, model, undefined, {
437
- codexRunId: run.runId,
438
- codexThreadId,
439
- ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
440
- ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
441
- })
523
+ try {
524
+ const terminalOwned = await callbacks.onDone(text, model, undefined, {
525
+ codexRunId: run.runId,
526
+ codexThreadId,
527
+ clientJobId: options?.clientJobId,
528
+ generation: options?.generation,
529
+ ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
530
+ ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
531
+ })
532
+ if (terminalOwned === false) return
533
+ } catch (error) {
534
+ console.error('[codex-bridge] terminal completion callback failed:', error)
535
+ }
442
536
 
443
537
  if (isFirstQuery) {
444
538
  notifySessionStart(sid, query)
@@ -447,23 +541,25 @@ export async function callCodexStreaming(
447
541
  notifyExchange(sid, query, text)
448
542
  }
449
543
 
450
- function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
544
+ async function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
451
545
  if (finalized) return
452
546
  finalized = true
453
547
  cleanup()
454
548
  cleanupImages()
455
549
  outputImagePublisher?.cleanup()
456
550
  removeExchange(sid, pendingUserExchange)
457
- if (engineSession) {
458
- clearCodexEngineSession(sid, model)
459
- }
460
- finishCodexRun(run.runId, {
551
+ clearEngineSessionBestEffort('provider_error')
552
+ finishRunBestEffort({
461
553
  status,
462
554
  startedAtMs: startTime,
463
555
  error: msg,
464
556
  exitCode,
465
557
  })
466
- callbacks.onError(safeCodexUserError(msg))
558
+ try {
559
+ await callbacks.onError(safeCodexUserError(msg))
560
+ } catch (error) {
561
+ console.error('[codex-bridge] terminal error callback failed:', error)
562
+ }
467
563
  }
468
564
 
469
565
  function handleAbort() {
@@ -580,11 +676,38 @@ export async function callCodexStreaming(
580
676
  }
581
677
 
582
678
  try {
679
+ const providerOwned = await callbacks.onProviderProcess?.({
680
+ provider: 'codex',
681
+ runId: run.runId,
682
+ pid: proc.pid,
683
+ clientJobId: options?.clientJobId,
684
+ generation: options?.generation,
685
+ })
686
+ if (providerOwned === false) {
687
+ proc.kill('SIGTERM')
688
+ finalized = true
689
+ cleanup()
690
+ cleanupImages()
691
+ outputImagePublisher?.cleanup()
692
+ removeExchange(sid, pendingUserExchange)
693
+ clearEngineSessionBestEffort('provider_ownership_lost')
694
+ finishRunBestEffort({
695
+ status: 'failed',
696
+ startedAtMs: startTime,
697
+ error: 'codex-bridge: durable provider ownership was lost.',
698
+ exitCode: null,
699
+ })
700
+ return sid
701
+ }
702
+ if (finalized) return sid
583
703
  proc.stdin.write(prompt)
584
704
  proc.stdin.end()
585
705
  } catch (err) {
586
706
  const message = err instanceof Error ? err.message : String(err)
587
- finalizeError(`codex-bridge: stdin failed — ${message}`)
707
+ if (!finalized) {
708
+ proc.kill('SIGTERM')
709
+ await finalizeError(`codex-bridge: provider start failed — ${message}`)
710
+ }
588
711
  }
589
712
 
590
713
  return sid
@@ -10,7 +10,7 @@ import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
10
10
  import { appendToArchive, type SessionToArchive } from './archive.js'
11
11
  import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
12
12
  import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-log.js'
13
- import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
+ import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
14
14
  import { localDay } from './local-day.js'
15
15
  import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
16
16
  import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
@@ -22,12 +22,32 @@ export interface Exchange {
22
22
  content: string
23
23
  timestamp: number
24
24
  globalMsgNum?: number // Client's global message number for this Q&A pair
25
+ /** Durable query provenance. Legacy exchanges omit both fields. */
26
+ clientJobId?: string
27
+ generation?: number
25
28
  /** Public attachment refs may live on either half of a Q&A pair: request
26
29
  * photos on the user exchange, model-published images on the assistant
27
30
  * exchange. Bytes, filesystem paths, and capabilities never persist here. */
28
31
  attachments?: MediaAttachmentRef[]
29
32
  }
30
33
 
34
+ export interface ExchangeJobProvenance {
35
+ clientJobId?: string
36
+ generation?: number
37
+ }
38
+
39
+ /** A durable job generation is the exact reconciliation/removal identity.
40
+ * Requiring both fields prevents a stale generation from mutating its retry. */
41
+ export interface ExchangeJobIdentity {
42
+ clientJobId: string
43
+ generation: number
44
+ }
45
+
46
+ export interface ReconciledExchange {
47
+ exchange: Exchange
48
+ created: boolean
49
+ }
50
+
31
51
  interface Session {
32
52
  id: string
33
53
  exchanges: Exchange[]
@@ -58,6 +78,46 @@ interface SessionsFile {
58
78
  savedAt: string
59
79
  }
60
80
 
81
+ const MAX_CLIENT_JOB_ID_LENGTH = 128
82
+ const SAFE_CLIENT_JOB_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
83
+
84
+ function validClientJobId(value: unknown): value is string {
85
+ return typeof value === 'string'
86
+ && value.length > 0
87
+ && value.length <= MAX_CLIENT_JOB_ID_LENGTH
88
+ && SAFE_CLIENT_JOB_ID_RE.test(value)
89
+ }
90
+
91
+ function validGeneration(value: unknown): value is number {
92
+ return typeof value === 'number'
93
+ && Number.isSafeInteger(value)
94
+ && value > 0
95
+ }
96
+
97
+ function normalizedExchangeProvenance(
98
+ provenance: ExchangeJobProvenance | undefined,
99
+ ): ExchangeJobProvenance {
100
+ if (!provenance || !validClientJobId(provenance.clientJobId)) return {}
101
+ return {
102
+ clientJobId: provenance.clientJobId,
103
+ ...(validGeneration(provenance.generation) ? { generation: provenance.generation } : {}),
104
+ }
105
+ }
106
+
107
+ function validateLoadedExchangeProvenance(exchange: Exchange): void {
108
+ if (!validClientJobId(exchange.clientJobId)) {
109
+ delete exchange.clientJobId
110
+ delete exchange.generation
111
+ return
112
+ }
113
+ if (!validGeneration(exchange.generation)) delete exchange.generation
114
+ }
115
+
116
+ function matchesJobIdentity(exchange: Exchange, identity: ExchangeJobIdentity): boolean {
117
+ return exchange.clientJobId === identity.clientJobId
118
+ && exchange.generation === identity.generation
119
+ }
120
+
61
121
  function loadFromDisk(): void {
62
122
  const result = loadJsonOrQuarantine<SessionsFile>(SESSION_FILE)
63
123
  if (result.status === 'missing') return // fresh start
@@ -81,6 +141,7 @@ function loadFromDisk(): void {
81
141
  // Persistence boundary: malformed refs are dropped without sacrificing
82
142
  // the surrounding exchange or the rest of the recovered session.
83
143
  for (const exchange of session.exchanges) {
144
+ validateLoadedExchangeProvenance(exchange)
84
145
  if ('attachments' in exchange && exchange.attachments !== undefined) {
85
146
  const refs = parseMediaAttachmentRefs(exchange.attachments)
86
147
  if (refs.length > 0) exchange.attachments = refs
@@ -121,6 +182,29 @@ function saveToDisk(): void {
121
182
  }
122
183
  }
123
184
 
185
+ /** Force the current in-memory conversation projection across an fsync-backed
186
+ * durability boundary. Durable query jobs call this only at terminal/recovery
187
+ * boundaries; the existing 500ms coalesced writer remains the normal hot path.
188
+ * Unlike saveToDisk(), failures are surfaced to the caller so it can suppress
189
+ * a compatibility notification and leave boot reconciliation to repair it. */
190
+ export function flushConversationToDisk(): void {
191
+ if (saveTimer) {
192
+ clearTimeout(saveTimer)
193
+ saveTimer = null
194
+ }
195
+ try {
196
+ mkdirSync(dirname(SESSION_FILE), { recursive: true })
197
+ const data: SessionsFile = {
198
+ sessions: Object.fromEntries(sessions),
199
+ savedAt: new Date().toISOString(),
200
+ }
201
+ durableAtomicWriteFileSync(SESSION_FILE, JSON.stringify(data, null, 2), { mode: 0o600 })
202
+ } catch (err) {
203
+ console.error('[conversation] Failed durable terminal flush:', err)
204
+ throw err
205
+ }
206
+ }
207
+
124
208
  // Restore on module load
125
209
  loadFromDisk()
126
210
 
@@ -316,6 +400,7 @@ export function addExchange(
316
400
  content: string,
317
401
  globalMsgNum?: number,
318
402
  attachments?: MediaAttachmentRef[],
403
+ provenance?: ExchangeJobProvenance,
319
404
  ): Exchange {
320
405
  let session = sessions.get(sessionId)
321
406
  if (!session) {
@@ -328,6 +413,7 @@ export function addExchange(
328
413
  content,
329
414
  timestamp: Date.now(),
330
415
  globalMsgNum,
416
+ ...normalizedExchangeProvenance(provenance),
331
417
  ...(attachments && attachments.length > 0 ? { attachments } : {}),
332
418
  }
333
419
  session.exchanges.push(exchange)
@@ -377,6 +463,113 @@ export function removeExchange(sessionId: string, exchange: Exchange): boolean {
377
463
  return true
378
464
  }
379
465
 
466
+ /** Return the exchanges owned by one exact durable job generation. The
467
+ * returned objects are the live exchange objects, matching getHistory's
468
+ * existing shallow-copy behavior. */
469
+ export function findExchangesByJobIdentity(
470
+ sessionId: string,
471
+ identity: ExchangeJobIdentity,
472
+ ): Exchange[] {
473
+ const session = sessions.get(sessionId)
474
+ if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return []
475
+ return session.exchanges.filter(exchange => matchesJobIdentity(exchange, identity))
476
+ }
477
+
478
+ /** Remove only exchanges owned by one exact job generation. A role filter is
479
+ * used by failed runs to roll back their pending user turn without touching a
480
+ * completed assistant exchange or a newer retry generation. */
481
+ export function removeExchangesByJobIdentity(
482
+ sessionId: string,
483
+ identity: ExchangeJobIdentity,
484
+ role?: Exchange['role'],
485
+ ): number {
486
+ const session = sessions.get(sessionId)
487
+ if (!session || !validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) return 0
488
+
489
+ const before = session.exchanges.length
490
+ session.exchanges = session.exchanges.filter(exchange => (
491
+ !matchesJobIdentity(exchange, identity) || (role !== undefined && exchange.role !== role)
492
+ ))
493
+ const removed = before - session.exchanges.length
494
+ if (removed === 0) return 0
495
+
496
+ session.lastActivity = Date.now()
497
+ scheduleSave()
498
+ scheduleCacheUpdate()
499
+ return removed
500
+ }
501
+
502
+ /** Idempotently create or update one side of a durable job exchange pair.
503
+ * Duplicate same-role rows for the exact identity are collapsed in the same
504
+ * synchronous mutation before the existing coalesced save/cache path runs.
505
+ * Other jobs, generations, and the opposite role are never changed. */
506
+ export function reconcileExchangeByJobIdentity(
507
+ sessionId: string,
508
+ identity: ExchangeJobIdentity,
509
+ role: Exchange['role'],
510
+ content: string,
511
+ globalMsgNum?: number,
512
+ attachments?: MediaAttachmentRef[],
513
+ ): ReconciledExchange {
514
+ if (!validClientJobId(identity.clientJobId) || !validGeneration(identity.generation)) {
515
+ throw new Error('conversation: invalid durable job identity')
516
+ }
517
+
518
+ let session = sessions.get(sessionId)
519
+ if (!session) {
520
+ const now = Date.now()
521
+ session = {
522
+ id: sessionId,
523
+ exchanges: [],
524
+ lastActivity: now,
525
+ createdAt: now,
526
+ modelPreference: null,
527
+ contextBreaks: [],
528
+ }
529
+ sessions.set(sessionId, session)
530
+ }
531
+
532
+ const matchingIndexes: number[] = []
533
+ for (let i = 0; i < session.exchanges.length; i++) {
534
+ const exchange = session.exchanges[i]
535
+ if (exchange.role === role && matchesJobIdentity(exchange, identity)) matchingIndexes.push(i)
536
+ }
537
+
538
+ const validatedAttachments = parseMediaAttachmentRefs(attachments)
539
+ let exchange: Exchange
540
+ const created = matchingIndexes.length === 0
541
+ if (created) {
542
+ exchange = {
543
+ role,
544
+ content,
545
+ timestamp: Date.now(),
546
+ globalMsgNum,
547
+ clientJobId: identity.clientJobId,
548
+ generation: identity.generation,
549
+ ...(validatedAttachments.length > 0 ? { attachments: validatedAttachments } : {}),
550
+ }
551
+ session.exchanges.push(exchange)
552
+ } else {
553
+ exchange = session.exchanges[matchingIndexes[0]]
554
+ exchange.content = content
555
+ exchange.globalMsgNum = globalMsgNum
556
+ if (validatedAttachments.length > 0) exchange.attachments = validatedAttachments
557
+ else delete exchange.attachments
558
+
559
+ // Remove from the end so earlier indexes stay stable; retain the oldest
560
+ // row's timestamp and position to preserve conversation ordering.
561
+ for (let i = matchingIndexes.length - 1; i >= 1; i--) {
562
+ session.exchanges.splice(matchingIndexes[i], 1)
563
+ }
564
+ }
565
+
566
+ while (session.exchanges.length > MAX_EXCHANGES) session.exchanges.shift()
567
+ session.lastActivity = Date.now()
568
+ scheduleSave()
569
+ scheduleCacheUpdate()
570
+ return { exchange, created }
571
+ }
572
+
380
573
  /** Clear a session — archive + log BEFORE deleting from the live Map.
381
574
  * Async so callers can `await` archive completion before considering the
382
575
  * session durable. If archive fails the session stays in the Map (retryable
@@ -0,0 +1,53 @@
1
+ export const LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION = 1 as const
2
+ export const LOCAL_FIRST_MEETING_IDLE_RETENTION_MS = 4 * 60 * 60 * 1000
3
+
4
+ export interface LocalFirstMeetingsCapability {
5
+ protocolVersion: typeof LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION
6
+ serverInstanceId: string
7
+ idempotentSave: true
8
+ sessionStatus: true
9
+ retentionMs: number
10
+ }
11
+
12
+ export type MeetingSessionState = 'active' | 'closed' | 'saved' | 'missing'
13
+ export type IndexRange = [start: number, end: number]
14
+
15
+ export function localFirstMeetingsCapability(serverInstanceId: string | null): LocalFirstMeetingsCapability | null {
16
+ if (!serverInstanceId) return null
17
+ return {
18
+ protocolVersion: LOCAL_FIRST_MEETINGS_PROTOCOL_VERSION,
19
+ serverInstanceId,
20
+ idempotentSave: true,
21
+ sessionStatus: true,
22
+ retentionMs: LOCAL_FIRST_MEETING_IDLE_RETENTION_MS,
23
+ }
24
+ }
25
+
26
+ /** Exact, compact representation of a sparse received-index ledger. */
27
+ export function compressIndexRanges(indices: readonly number[]): IndexRange[] {
28
+ const sorted = Array.from(new Set(
29
+ indices.filter(value => Number.isInteger(value) && value >= 0),
30
+ )).sort((a, b) => a - b)
31
+ if (sorted.length === 0) return []
32
+
33
+ const ranges: IndexRange[] = []
34
+ let start = sorted[0]
35
+ let end = start
36
+ for (let index = 1; index < sorted.length; index++) {
37
+ const value = sorted[index]
38
+ if (value === end + 1) {
39
+ end = value
40
+ continue
41
+ }
42
+ ranges.push([start, end])
43
+ start = value
44
+ end = value
45
+ }
46
+ ranges.push([start, end])
47
+ return ranges
48
+ }
49
+
50
+ export function retainedUntilIso(lastActivityAt: number | null): string | null {
51
+ if (lastActivityAt == null || !Number.isFinite(lastActivityAt)) return null
52
+ return new Date(lastActivityAt + LOCAL_FIRST_MEETING_IDLE_RETENTION_MS).toISOString()
53
+ }
@@ -16,7 +16,7 @@ import type { ModelImageInput } from './model-image-input.js'
16
16
  // mutating the same conversation/CLI session concurrently.
17
17
  const sessionRunTails = new Map<string, Promise<void>>()
18
18
 
19
- async function acquireSessionRunLock(sessionId: string): Promise<() => void> {
19
+ export async function acquireModelSessionRunLock(sessionId: string): Promise<() => void> {
20
20
  const previous = sessionRunTails.get(sessionId) ?? Promise.resolve()
21
21
  let openGate!: () => void
22
22
  const gate = new Promise<void>(resolve => { openGate = resolve })
@@ -47,7 +47,7 @@ export async function callModelStreaming(
47
47
  options?: CallOptions,
48
48
  ): Promise<string> {
49
49
  const sid = getOrCreateSession(sessionId)
50
- const release = await acquireSessionRunLock(sid)
50
+ const release = options?.sessionLockHeld ? (() => {}) : await acquireModelSessionRunLock(sid)
51
51
  if (options?.abortSignal?.aborted) {
52
52
  release()
53
53
  throw new Error('model-router: request aborted before the model run started.')
@@ -70,16 +70,16 @@ export async function callModelStreaming(
70
70
  }
71
71
  const lockedCallbacks: StreamCallbacks = {
72
72
  ...callbacks,
73
- onDone: (fullText, completedModel, cliSessionId, metadata) => {
73
+ onDone: async (fullText, completedModel, cliSessionId, metadata) => {
74
74
  try {
75
- callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
75
+ return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
76
76
  } finally {
77
77
  releaseTerminal()
78
78
  }
79
79
  },
80
- onError: (error) => {
80
+ onError: async (error) => {
81
81
  try {
82
- callbacks.onError(error)
82
+ await callbacks.onError(error)
83
83
  } finally {
84
84
  releaseTerminal()
85
85
  }