@gotcos/glasses-server 6.9.0 → 6.10.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.
package/.env.example CHANGED
@@ -28,6 +28,14 @@ BIND_HOST=0.0.0.0
28
28
  # server after Wi-Fi/Tailscale changes and process restarts.
29
29
  # COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
30
30
 
31
+ # Build 204+ can opt into server-owned durable query jobs. The server fsyncs an
32
+ # accepted prompt before returning 202, then keeps provider work running if the
33
+ # phone backgrounds, reloads, or changes networks. Reopening COS reattaches to
34
+ # the same job. Set to 0 (or remove) to send NEW prompts through the legacy
35
+ # streaming route; already accepted durable jobs remain readable/cancellable
36
+ # and drain safely.
37
+ # COS_DURABLE_QUERY_JOBS=1
38
+
31
39
  # ── THE LLM (chat) ──────────────────────────────────────────────────────
32
40
  # Chat runs through your LOCAL agent CLI — NOT an API key:
33
41
  # Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.10.0
4
+
5
+ Opt-in server-owned durable query jobs for COS Glasses build 204+.
6
+
7
+ - **Accepted means durable.** With `COS_DURABLE_QUERY_JOBS=1`, the server
8
+ appends and fsyncs an immutable job before returning 202. Provider execution
9
+ is no longer owned by the phone's current request, WebView, or SSE subscriber.
10
+ - **Reconnect without duplication.** The client can recover an ambiguous
11
+ admission by its stable client job ID, replay ordered bounded events, and
12
+ acknowledge one terminal projection idempotently after message, queue,
13
+ counter, and session state are durable on the phone.
14
+ - **Crash and cancellation fences.** Provider ownership is persisted before
15
+ input, session-scoped leases prevent overlapping orphan continuations after a
16
+ restart, cancellation is durable, and answer-ready ownership gates
17
+ conversation, image, notification, and Done side effects.
18
+ - **Private bounded storage.** The append-only journal uses private directory
19
+ and file modes, repairs torn tails, bounds progress/activity payloads, and
20
+ retains terminal jobs for exactly seven days.
21
+ - **Safe rollout and rollback.** The health capability advertises exact protocol
22
+ version 1 only when configured and the store is ready. Removing the flag
23
+ blocks new durable admissions but leaves GET/events/cancel/ack available so
24
+ accepted jobs drain; legacy queries, first turns, handoffs, and older clients
25
+ remain unchanged.
26
+
3
27
  ## 6.9.0
4
28
 
5
29
  Live recoverable prompt transcription for COS Glasses builds 200+.
package/README.md CHANGED
@@ -56,6 +56,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
56
56
  ## What it does
57
57
 
58
58
  - Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
59
+ - With COS Glasses build 204+, opt into server-owned durable queries with
60
+ `COS_DURABLE_QUERY_JOBS=1`: accepted work survives phone backgrounding,
61
+ WebView reloads, and network handoffs, then reattaches without duplicate work
62
+ or duplicate replies
59
63
  - Choose Opus, Fable, Sonnet, GPT Frontier, or GPT Balanced plus High, Extra
60
64
  High, Max, or Ultracode effort; optional redacted tool activity streams only
61
65
  to the authenticated query that requested it
@@ -78,7 +82,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
78
82
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
79
83
  optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
80
84
  `COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
81
- `COS_SCRIPTS_DIR` (full pipeline), and `COS_MEDIA_ROOT` (optional image-store
85
+ `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
86
+ server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
82
87
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
83
88
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
84
89
 
@@ -99,6 +104,10 @@ BIND_HOST=0.0.0.0 npm run start:server
99
104
  - *Voice getting billed?* — install `whisper-cpp` for free local transcription.
100
105
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
101
106
  - *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
107
+ - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
108
+ `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
109
+ `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
110
+ back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
102
111
 
103
112
  ## License
104
113
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.9.0",
3
+ "version": "6.10.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -43,6 +43,13 @@ import { getMediaStore } from './lib/media-store.js'
43
43
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
44
44
  import { serverMetrics } from './lib/server-metrics.js'
45
45
  import { initializeServerInstanceId } from './lib/server-instance-id.js'
46
+ import { createQueryJobsRouter } from './routes/query-jobs.js'
47
+ import {
48
+ initQueryJobRuntime,
49
+ preparePublicDurableQueryAdmission,
50
+ queryJobCoordinator,
51
+ shutdownQueryJobRuntime,
52
+ } from './lib/query-job-runtime.js'
46
53
 
47
54
  const app = express()
48
55
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -139,6 +146,9 @@ app.use((_req, _res, next) => {
139
146
  // API routes
140
147
  app.use('/api', healthRouter)
141
148
  app.use('/api', diagRouter)
149
+ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
150
+ prepareAdmission: preparePublicDurableQueryAdmission,
151
+ }))
142
152
  app.use('/api', queryRouter)
143
153
  app.use('/api', transcribeRouter)
144
154
  app.use('/api', displayRouter)
@@ -173,21 +183,28 @@ app.get('/', (_req, res) => {
173
183
  )
174
184
  })
175
185
 
176
- // Graceful shutdown stop whisper-server child process
177
- process.on('SIGTERM', () => {
178
- // Production stops (kill, service managers) send SIGTERM — flush session logs
179
- // exactly like SIGINT so active conversations aren't lost on shutdown.
186
+ // Graceful shutdown persists an interrupted terminal before provider abort.
187
+ // The bounded force-exit keeps service managers from hanging forever on a
188
+ // broken disk while retaining the previous session/catalog/Whisper cleanup.
189
+ let gracefulShutdownStarted = false
190
+ async function gracefulShutdown(): Promise<void> {
191
+ if (gracefulShutdownStarted) return
192
+ gracefulShutdownStarted = true
193
+ const forceExit = setTimeout(() => process.exit(1), 8_000)
194
+ forceExit.unref?.()
195
+ try {
196
+ await shutdownQueryJobRuntime('server_shutdown')
197
+ } catch (error) {
198
+ console.error('[query-jobs] graceful interruption failed:', error)
199
+ }
180
200
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
181
201
  stopCodexModelCatalogRefresh()
182
202
  stopWhisperServer()
203
+ clearTimeout(forceExit)
183
204
  process.exit(0)
184
- })
185
- process.on('SIGINT', () => {
186
- logActiveSessionsOnShutdown()
187
- stopCodexModelCatalogRefresh()
188
- stopWhisperServer()
189
- process.exit(0)
190
- })
205
+ }
206
+ process.on('SIGTERM', () => { void gracefulShutdown() })
207
+ process.on('SIGINT', () => { void gracefulShutdown() })
191
208
 
192
209
  // Crash protection for runtime work. Listener failures are handled separately
193
210
  // and exit immediately so a supervisor can restart a clean, unified process.
@@ -228,6 +245,18 @@ listenRequiredServers(listeners).then(() => {
228
245
  console.log(`[COS API] Server instance: ${serverInstanceId}`)
229
246
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
230
247
 
248
+ void initQueryJobRuntime().then(health => {
249
+ if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
250
+ console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
251
+ } else {
252
+ console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
253
+ }
254
+ }).catch(error => {
255
+ // The store remains degraded and rejects admission. Legacy /api/query is
256
+ // still mounted, so disabling the feature flag is an immediate rollback.
257
+ console.error('[COS API] Durable query-job store unavailable:', error)
258
+ })
259
+
231
260
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
232
261
  // not paste-able — enumerate real interfaces and label the Tailscale one.
233
262
  try {
@@ -38,6 +38,7 @@ import {
38
38
  type ActivityPreviewLine,
39
39
  } from './activity-preview.js'
40
40
  import {
41
+ collectRunOutputImagesBounded,
41
42
  createRunOutputImagePublisher,
42
43
  isRunOutputImagePublisherCommand,
43
44
  type RunOutputImageCollectionStats,
@@ -291,19 +292,34 @@ function isExtendedQuery(query: string): boolean {
291
292
  }
292
293
 
293
294
  export interface ModelRunMetadata {
295
+ claudeRunId?: string
296
+ clientJobId?: string
297
+ generation?: number
294
298
  codexRunId?: string
295
299
  codexThreadId?: string
296
300
  outputAttachments?: MediaAttachmentRef[]
297
301
  outputImageStats?: RunOutputImageCollectionStats
298
302
  }
299
303
 
304
+ /** Public-safe provider launch metadata for durable job coordination. It
305
+ * deliberately exposes no ChildProcess object, kill handle, paths, or env. */
306
+ export interface ProviderProcessMetadata {
307
+ provider: 'claude' | 'codex'
308
+ runId: string
309
+ pid?: number
310
+ clientJobId?: string
311
+ generation?: number
312
+ }
313
+
300
314
  export interface StreamCallbacks {
301
315
  onChunk: (text: string) => void
302
- onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => void
303
- onError: (error: string) => void
316
+ onAnswerReady?: (fullText: string) => boolean | void | Promise<boolean | void>
317
+ onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => boolean | void | Promise<boolean | void>
318
+ onError: (error: string) => void | Promise<void>
304
319
  onToolStatus?: (toolName: string) => void
305
320
  onActivityLine?: (line: ActivityPreviewLine) => void
306
321
  onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
322
+ onProviderProcess?: (metadata: ProviderProcessMetadata) => boolean | void | Promise<boolean | void>
307
323
  }
308
324
 
309
325
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
@@ -336,6 +352,10 @@ export interface CallOptions {
336
352
  lightweight?: boolean // Skip async context fetch — use cached context instantly (G2 speed path)
337
353
  abortSignal?: AbortSignal
338
354
  effort?: EffortPreference
355
+ clientJobId?: string
356
+ generation?: number
357
+ /** Durable coordinator already owns the per-session provider lease. */
358
+ sessionLockHeld?: boolean
339
359
  }
340
360
 
341
361
  export async function callClaudeStreaming(
@@ -367,7 +387,10 @@ export async function callClaudeStreaming(
367
387
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
368
388
  const resolvedCliKey = cliSessionKey(sid, resolvedModel)
369
389
  let existingCliSession = cliSessionMap.get(resolvedCliKey)
370
- callbacks.onStart?.(resolvedModel, sid, existingCliSession)
390
+ callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
391
+ clientJobId: options?.clientJobId,
392
+ generation: options?.generation,
393
+ })
371
394
 
372
395
  // Phase: context loading (skipped in lightweight mode)
373
396
  let phase: Phase = 'context'
@@ -414,7 +437,18 @@ export async function callClaudeStreaming(
414
437
  // Record user message (with [Photo]/[N Photos] prefix for vision queries)
415
438
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
416
439
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
417
- const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
440
+ const exchangeProvenance = {
441
+ clientJobId: options?.clientJobId,
442
+ generation: options?.generation,
443
+ }
444
+ const pendingUserExchange = addExchange(
445
+ sid,
446
+ 'user',
447
+ historyQuery,
448
+ globalMsgNum,
449
+ undefined,
450
+ exchangeProvenance,
451
+ )
418
452
 
419
453
  // Vision queries need the Read tool to see the image files
420
454
  const baseTools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
@@ -519,15 +553,62 @@ export async function callClaudeStreaming(
519
553
  cleanupModelImageInputs(imageInputs)
520
554
  }
521
555
 
556
+ function abandonLostDurableOwnership(message: string) {
557
+ finalized = true
558
+ cleanup()
559
+ cleanupImages()
560
+ outputImagePublisher?.cleanup()
561
+ removeExchange(sid, pendingUserExchange)
562
+ if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
563
+ finishClaudeRun(run.runId, {
564
+ status: 'failed',
565
+ startedAtMs: startTime,
566
+ error: message,
567
+ exitCode: null,
568
+ })
569
+ }
570
+
522
571
  async function finalize(text: string) {
523
572
  if (finalized) return
524
573
  finalized = true
525
574
  cleanup()
526
575
  cleanupImages()
527
576
 
528
- // Persist text before output-image normalization. A daemon crash during
529
- // finalization cannot erase an otherwise successful answer.
530
- const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
577
+ // The coordinator persists the final provider text before conversation
578
+ // mutation, condensation, or output-image normalization can stall/crash.
579
+ try {
580
+ const answerOwned = await callbacks.onAnswerReady?.(text)
581
+ if (answerOwned === false) {
582
+ abandonLostDurableOwnership('claude-bridge: durable answer ownership was lost.')
583
+ return
584
+ }
585
+ } catch (error) {
586
+ console.error('[claude-bridge] durable answer barrier failed:', error)
587
+ outputImagePublisher?.cleanup()
588
+ removeExchange(sid, pendingUserExchange)
589
+ if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
590
+ finishClaudeRun(run.runId, {
591
+ status: 'failed',
592
+ startedAtMs: startTime,
593
+ error: 'claude-bridge: durable answer persistence failed.',
594
+ exitCode: null,
595
+ })
596
+ try {
597
+ await callbacks.onError('claude-bridge: durable answer persistence failed.')
598
+ } catch (callbackError) {
599
+ console.error('[claude-bridge] durable barrier error callback failed:', callbackError)
600
+ }
601
+ return
602
+ }
603
+
604
+ const assistantExchange = addExchange(
605
+ sid,
606
+ 'assistant',
607
+ text,
608
+ globalMsgNum,
609
+ undefined,
610
+ exchangeProvenance,
611
+ )
531
612
  if (imagePaths.length > 0) {
532
613
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
533
614
  }
@@ -539,7 +620,9 @@ export async function callClaudeStreaming(
539
620
  const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
540
621
  preparingHeartbeat.unref?.()
541
622
  try {
542
- outputAttachments = await outputImagePublisher.collect()
623
+ outputAttachments = await collectRunOutputImagesBounded(outputImagePublisher, {
624
+ signal: options?.abortSignal,
625
+ })
543
626
  } catch (err) {
544
627
  console.error('[claude-bridge] output image collection failed:', err)
545
628
  } finally {
@@ -576,10 +659,14 @@ export async function callClaudeStreaming(
576
659
  exitCode: 0,
577
660
  })
578
661
 
579
- callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
662
+ const terminalOwned = await callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
663
+ claudeRunId: run.runId,
664
+ clientJobId: options?.clientJobId,
665
+ generation: options?.generation,
580
666
  ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
581
667
  ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
582
668
  })
669
+ if (terminalOwned === false) return
583
670
 
584
671
  // Telegram notifications — fire and forget
585
672
  if (isFirstQuery) {
@@ -589,7 +676,7 @@ export async function callClaudeStreaming(
589
676
  notifyExchange(sid, query, text)
590
677
  }
591
678
 
592
- function finalizeError(
679
+ async function finalizeError(
593
680
  msg: string,
594
681
  exitCode?: number | null,
595
682
  status: Exclude<ClaudeRunStatus, 'running'> = 'failed',
@@ -610,7 +697,7 @@ export async function callClaudeStreaming(
610
697
  error: msg,
611
698
  exitCode,
612
699
  })
613
- callbacks.onError(msg)
700
+ await callbacks.onError(msg)
614
701
  }
615
702
 
616
703
  function handleAbort() {
@@ -827,11 +914,27 @@ export async function callClaudeStreaming(
827
914
  ? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
828
915
  : fullQuery
829
916
  try {
917
+ const providerOwned = await callbacks.onProviderProcess?.({
918
+ provider: 'claude',
919
+ runId: run.runId,
920
+ pid: proc.pid,
921
+ clientJobId: options?.clientJobId,
922
+ generation: options?.generation,
923
+ })
924
+ if (providerOwned === false) {
925
+ proc.kill('SIGTERM')
926
+ abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
927
+ return sid
928
+ }
929
+ if (finalized) return sid
830
930
  proc.stdin.write(cliQuery)
831
931
  proc.stdin.end()
832
932
  } catch (err) {
833
933
  const message = err instanceof Error ? err.message : String(err)
834
- finalizeError(`claude-bridge: stdin failed — ${message}`, null)
934
+ if (!finalized) {
935
+ proc.kill('SIGTERM')
936
+ await finalizeError(`claude-bridge: provider start failed — ${message}`, null)
937
+ }
835
938
  }
836
939
 
837
940
  return sid
@@ -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