@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 +8 -0
- package/CHANGELOG.md +24 -0
- package/README.md +10 -1
- package/package.json +1 -1
- package/server/index.ts +40 -11
- package/server/lib/claude-bridge.ts +115 -12
- package/server/lib/codex-bridge.ts +151 -28
- package/server/lib/conversation.ts +194 -1
- package/server/lib/model-router.ts +6 -6
- package/server/lib/query-job-coordinator.ts +580 -0
- package/server/lib/query-job-feature.ts +20 -0
- package/server/lib/query-job-runtime.ts +255 -0
- package/server/lib/query-job-store.ts +1102 -0
- package/server/lib/query-job-types.ts +358 -0
- package/server/lib/run-output-images.ts +44 -0
- package/server/routes/health.ts +62 -2
- package/server/routes/query-jobs.ts +294 -0
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),
|
|
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
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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('
|
|
186
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
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
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
517
|
+
finishRunBestEffort({
|
|
431
518
|
status: 'completed',
|
|
432
519
|
startedAtMs: startTime,
|
|
433
520
|
output: text,
|
|
434
521
|
exitCode: 0,
|
|
435
522
|
})
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|