@gotcos/glasses-server 6.3.1 → 6.6.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.
Files changed (37) hide show
  1. package/.env.example +23 -7
  2. package/CHANGELOG.md +108 -0
  3. package/README.md +28 -8
  4. package/bin/cli.cjs +22 -10
  5. package/package.json +18 -6
  6. package/server/bin/cos-output-image-publisher.mjs +324 -0
  7. package/server/bootstrap.ts +16 -0
  8. package/server/index.ts +61 -21
  9. package/server/lib/activity-preview.ts +168 -0
  10. package/server/lib/archive.ts +20 -6
  11. package/server/lib/claude-bridge.ts +215 -60
  12. package/server/lib/claude-run-ledger.ts +7 -2
  13. package/server/lib/codex-bridge.ts +186 -71
  14. package/server/lib/codex-engine-sessions.ts +24 -2
  15. package/server/lib/codex-model-catalog.ts +450 -0
  16. package/server/lib/codex-run-ledger.ts +20 -4
  17. package/server/lib/conversation.ts +64 -2
  18. package/server/lib/display-bus.ts +61 -3
  19. package/server/lib/image-safety.ts +458 -0
  20. package/server/lib/listener-startup.ts +29 -0
  21. package/server/lib/media-store.ts +833 -0
  22. package/server/lib/model-image-input.ts +27 -0
  23. package/server/lib/model-router.ts +67 -8
  24. package/server/lib/query-attachments.ts +132 -0
  25. package/server/lib/run-output-images.ts +442 -0
  26. package/server/lib/server-instance-id.ts +55 -0
  27. package/server/lib/server-instance-lock.ts +122 -0
  28. package/server/lib/server-metrics.ts +7 -0
  29. package/server/routes/display.ts +43 -22
  30. package/server/routes/health.ts +19 -2
  31. package/server/routes/media.ts +285 -0
  32. package/server/routes/message-ref.ts +18 -6
  33. package/server/routes/openai-compat.ts +44 -11
  34. package/server/routes/query.ts +51 -16
  35. package/server/routes/sessions.ts +33 -4
  36. package/shared/media-attachment.ts +126 -0
  37. package/shared/model-preference.ts +140 -17
@@ -0,0 +1,450 @@
1
+ // Runtime Codex model discovery.
2
+ //
3
+ // The client persists stable frontier/balanced slots. This module resolves
4
+ // those slots to concrete model ids through Codex's official `model/list`
5
+ // method. A last-known-good catalog is retained across transient failures;
6
+ // the CLI default is used only when no discovered catalog exists yet.
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import { existsSync, readFileSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { resolve } from 'node:path'
12
+ import {
13
+ CODEX_BALANCED_MODEL,
14
+ CODEX_FRONTIER_MODEL,
15
+ CODEX_MODEL_ID,
16
+ CODEX_SERVICE_TIER,
17
+ resolveConfiguredCodexReasoningEffort,
18
+ resolveCodexReasoningEffort,
19
+ setRuntimeCodexModelLabels,
20
+ type CodexModelPreference,
21
+ type EffortPreference,
22
+ } from '../../shared/model-preference.js'
23
+
24
+ const DEFAULT_REFRESH_TTL_MS = 15 * 60_000
25
+ const DEFAULT_REFRESH_TIMEOUT_MS = 7_000
26
+ const MIN_PERIODIC_REFRESH_MS = 60_000
27
+
28
+ export type CodexCatalogSource = 'app-server' | 'disk-cache' | 'cli-default'
29
+
30
+ export interface CodexCatalogModel {
31
+ id: string
32
+ displayName: string
33
+ description: string
34
+ hidden: boolean
35
+ supportedReasoningEfforts: string[]
36
+ defaultReasoningEffort: string
37
+ serviceTiers: string[]
38
+ isDefault: boolean
39
+ }
40
+
41
+ export interface CodexModelOption extends CodexCatalogModel {
42
+ preference: CodexModelPreference
43
+ }
44
+
45
+ export interface CodexModelCatalog {
46
+ source: CodexCatalogSource
47
+ refreshedAt: string
48
+ autoUpdates: true
49
+ selectionPolicy: 'newest-generation-top-two'
50
+ options: CodexModelOption[]
51
+ refreshError?: string
52
+ }
53
+
54
+ export type CodexCatalogFetcher = () => Promise<CodexCatalogModel[]>
55
+
56
+ function modelCachePath(): string {
57
+ return resolve(
58
+ process.env.CODEX_HOME?.trim() || resolve(homedir(), '.codex'),
59
+ 'models_cache.json',
60
+ )
61
+ }
62
+
63
+ function formatDisplayName(value: string): string {
64
+ return value.replace(/^(GPT-\d+(?:\.\d+)+)-/i, '$1 ').trim()
65
+ }
66
+
67
+ function versionParts(modelId: string): number[] | null {
68
+ const match = /^gpt-(\d+(?:\.\d+)*)/i.exec(modelId)
69
+ if (!match) return null
70
+ return match[1].split('.').map(Number)
71
+ }
72
+
73
+ function compareVersionsDesc(a: number[], b: number[]): number {
74
+ const width = Math.max(a.length, b.length)
75
+ for (let i = 0; i < width; i++) {
76
+ const diff = (b[i] ?? 0) - (a[i] ?? 0)
77
+ if (diff !== 0) return diff
78
+ }
79
+ return 0
80
+ }
81
+
82
+ function sameVersion(a: number[], b: number[]): boolean {
83
+ return compareVersionsDesc(a, b) === 0
84
+ }
85
+
86
+ function isCapabilityCandidate(model: CodexCatalogModel): boolean {
87
+ const identity = `${model.id} ${model.displayName}`
88
+ if (/(?:^|[-\s])(mini|nano|spark|review)(?:$|[-\s])/i.test(identity)) return false
89
+ return !/(?:fast and affordable|cost-efficient|small,? fast|ultra-fast)/i.test(model.description)
90
+ }
91
+
92
+ /** Select two full-size models from the newest visible GPT generation. */
93
+ export function selectTopCodexModels(models: CodexCatalogModel[]): CodexCatalogModel[] {
94
+ const visible = models.filter(model => !model.hidden && versionParts(model.id))
95
+ if (visible.length === 0) return []
96
+
97
+ const capable = visible.filter(isCapabilityCandidate)
98
+ const pool = capable.length > 0 ? capable : visible
99
+ const versions = pool
100
+ .map(model => versionParts(model.id))
101
+ .filter((parts): parts is number[] => !!parts)
102
+ .sort(compareVersionsDesc)
103
+ const newest = versions[0]
104
+ if (!newest) return pool.slice(0, 2)
105
+
106
+ const selected = pool.filter(model => {
107
+ const parts = versionParts(model.id)
108
+ return !!parts && sameVersion(parts, newest)
109
+ }).slice(0, 2)
110
+
111
+ if (selected.length < 2) {
112
+ const older = pool
113
+ .filter(model => !selected.includes(model))
114
+ .map((model, index) => ({ model, version: versionParts(model.id) ?? [], index }))
115
+ .sort((a, b) => compareVersionsDesc(a.version, b.version) || a.index - b.index)
116
+ for (const entry of older) {
117
+ selected.push(entry.model)
118
+ if (selected.length === 2) break
119
+ }
120
+ }
121
+
122
+ return selected
123
+ }
124
+
125
+ export function buildCodexModelCatalog(
126
+ models: CodexCatalogModel[],
127
+ source: CodexCatalogSource,
128
+ refreshedAt = new Date().toISOString(),
129
+ ): CodexModelCatalog {
130
+ const selected = selectTopCodexModels(models)
131
+ const slots: CodexModelPreference[] = [CODEX_FRONTIER_MODEL, CODEX_BALANCED_MODEL]
132
+ const options = selected.map((model, index): CodexModelOption => ({
133
+ ...model,
134
+ displayName: formatDisplayName(model.displayName || model.id),
135
+ preference: slots[index],
136
+ }))
137
+
138
+ setRuntimeCodexModelLabels(options)
139
+ return {
140
+ source,
141
+ refreshedAt,
142
+ autoUpdates: true,
143
+ selectionPolicy: 'newest-generation-top-two',
144
+ options,
145
+ }
146
+ }
147
+
148
+ function cliDefaultCatalog(refreshError?: string): CodexModelCatalog {
149
+ const common = {
150
+ id: '',
151
+ description: 'Uses the current Codex CLI default until model discovery is available.',
152
+ hidden: false,
153
+ supportedReasoningEfforts: ['high', 'xhigh'],
154
+ defaultReasoningEffort: 'high',
155
+ serviceTiers: [] as string[],
156
+ isDefault: true,
157
+ }
158
+ const catalog: CodexModelCatalog = {
159
+ source: 'cli-default',
160
+ refreshedAt: new Date().toISOString(),
161
+ autoUpdates: true,
162
+ selectionPolicy: 'newest-generation-top-two',
163
+ options: [
164
+ { ...common, preference: CODEX_FRONTIER_MODEL, displayName: 'GPT Frontier (auto)' },
165
+ { ...common, preference: CODEX_BALANCED_MODEL, displayName: 'GPT Balanced (auto)', isDefault: false },
166
+ ],
167
+ ...(refreshError ? { refreshError } : {}),
168
+ }
169
+ setRuntimeCodexModelLabels(catalog.options)
170
+ return catalog
171
+ }
172
+
173
+ function normalizeAppServerModel(raw: any): CodexCatalogModel | null {
174
+ const id = typeof raw?.model === 'string' ? raw.model : typeof raw?.id === 'string' ? raw.id : ''
175
+ if (!id) return null
176
+ const efforts = Array.isArray(raw?.supportedReasoningEfforts)
177
+ ? raw.supportedReasoningEfforts
178
+ .map((item: any) => typeof item === 'string' ? item : item?.reasoningEffort)
179
+ .filter((value: unknown): value is string => typeof value === 'string' && !!value)
180
+ : []
181
+ const serviceTiers = Array.isArray(raw?.serviceTiers)
182
+ ? raw.serviceTiers
183
+ .map((item: any) => typeof item === 'string' ? item : item?.id)
184
+ .filter((value: unknown): value is string => typeof value === 'string' && !!value)
185
+ : []
186
+ return {
187
+ id,
188
+ displayName: typeof raw?.displayName === 'string' ? raw.displayName : id,
189
+ description: typeof raw?.description === 'string' ? raw.description : '',
190
+ hidden: raw?.hidden === true,
191
+ supportedReasoningEfforts: efforts,
192
+ defaultReasoningEffort: typeof raw?.defaultReasoningEffort === 'string' ? raw.defaultReasoningEffort : 'high',
193
+ serviceTiers,
194
+ isDefault: raw?.isDefault === true,
195
+ }
196
+ }
197
+
198
+ function readDiskCatalog(): CodexModelCatalog | null {
199
+ const path = modelCachePath()
200
+ if (!existsSync(path)) return null
201
+ try {
202
+ const parsed = JSON.parse(readFileSync(path, 'utf8'))
203
+ const models = Array.isArray(parsed?.models)
204
+ ? parsed.models.map((raw: any): CodexCatalogModel | null => {
205
+ const id = typeof raw?.slug === 'string' ? raw.slug : ''
206
+ if (!id) return null
207
+ return {
208
+ id,
209
+ displayName: typeof raw?.display_name === 'string' ? raw.display_name : id,
210
+ description: typeof raw?.description === 'string' ? raw.description : '',
211
+ hidden: raw?.visibility === 'hide',
212
+ supportedReasoningEfforts: Array.isArray(raw?.supported_reasoning_levels)
213
+ ? raw.supported_reasoning_levels
214
+ .map((item: any) => item?.effort)
215
+ .filter((value: unknown): value is string => typeof value === 'string' && !!value)
216
+ : [],
217
+ defaultReasoningEffort: typeof raw?.default_reasoning_level === 'string'
218
+ ? raw.default_reasoning_level
219
+ : 'high',
220
+ serviceTiers: Array.isArray(raw?.service_tiers)
221
+ ? raw.service_tiers
222
+ .map((item: any) => item?.id)
223
+ .filter((value: unknown): value is string => typeof value === 'string' && !!value)
224
+ : [],
225
+ isDefault: raw?.is_default === true,
226
+ }
227
+ }).filter((model: CodexCatalogModel | null): model is CodexCatalogModel => !!model)
228
+ : []
229
+ if (models.length === 0) return null
230
+ const refreshedAt = typeof parsed?.fetched_at === 'string' ? parsed.fetched_at : new Date().toISOString()
231
+ return buildCodexModelCatalog(models, 'disk-cache', refreshedAt)
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ function refreshTimeoutMs(): number {
238
+ const raw = Number(process.env.COS_CODEX_MODEL_REFRESH_TIMEOUT_MS ?? DEFAULT_REFRESH_TIMEOUT_MS)
239
+ return Number.isFinite(raw) && raw >= 1_000 ? Math.floor(raw) : DEFAULT_REFRESH_TIMEOUT_MS
240
+ }
241
+
242
+ function refreshTtlMs(): number {
243
+ const raw = Number(process.env.COS_CODEX_MODEL_REFRESH_TTL_MS ?? DEFAULT_REFRESH_TTL_MS)
244
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_REFRESH_TTL_MS
245
+ }
246
+
247
+ async function fetchAppServerModels(): Promise<CodexCatalogModel[]> {
248
+ return new Promise((resolveModels, reject) => {
249
+ const env = { ...process.env }
250
+ delete env.CLAUDECODE
251
+ const child = spawn('codex', ['app-server', '--stdio'], {
252
+ stdio: ['pipe', 'pipe', 'pipe'],
253
+ env,
254
+ })
255
+ let settled = false
256
+ let stdoutBuffer = ''
257
+ let stderr = ''
258
+
259
+ const finish = (err?: Error, models?: CodexCatalogModel[]) => {
260
+ if (settled) return
261
+ settled = true
262
+ clearTimeout(timer)
263
+ try { child.stdin.end() } catch { /* ignore */ }
264
+ try { child.kill() } catch { /* ignore */ }
265
+ if (err) reject(err)
266
+ else resolveModels(models ?? [])
267
+ }
268
+
269
+ const timer = setTimeout(() => finish(new Error('Codex model discovery timed out')), refreshTimeoutMs())
270
+
271
+ child.on('error', err => finish(err))
272
+ child.on('close', code => {
273
+ if (!settled) finish(new Error(`Codex model discovery exited ${code}: ${stderr.slice(0, 160)}`))
274
+ })
275
+ child.stderr.on('data', chunk => {
276
+ stderr = (stderr + String(chunk)).slice(-1_000)
277
+ })
278
+ child.stdout.on('data', chunk => {
279
+ stdoutBuffer += String(chunk)
280
+ for (;;) {
281
+ const newline = stdoutBuffer.indexOf('\n')
282
+ if (newline < 0) break
283
+ const line = stdoutBuffer.slice(0, newline).trim()
284
+ stdoutBuffer = stdoutBuffer.slice(newline + 1)
285
+ if (!line) continue
286
+ try {
287
+ const message = JSON.parse(line)
288
+ if (message?.id !== 2) continue
289
+ if (message?.error) {
290
+ finish(new Error('Codex model/list failed'))
291
+ return
292
+ }
293
+ const models = Array.isArray(message?.result?.data)
294
+ ? message.result.data
295
+ .map(normalizeAppServerModel)
296
+ .filter((model: CodexCatalogModel | null): model is CodexCatalogModel => !!model)
297
+ : []
298
+ if (models.length === 0) {
299
+ finish(new Error('Codex model/list returned no models'))
300
+ return
301
+ }
302
+ finish(undefined, models)
303
+ return
304
+ } catch {
305
+ // Notifications and partial/non-JSON diagnostics are irrelevant.
306
+ }
307
+ }
308
+ })
309
+
310
+ child.stdin.write(JSON.stringify({
311
+ id: 1,
312
+ method: 'initialize',
313
+ params: {
314
+ clientInfo: { name: 'cos-glasses-server', version: '6.4.0' },
315
+ capabilities: { experimentalApi: false },
316
+ },
317
+ }) + '\n')
318
+ child.stdin.write(JSON.stringify({
319
+ id: 2,
320
+ method: 'model/list',
321
+ params: { limit: 50, includeHidden: false },
322
+ }) + '\n')
323
+ })
324
+ }
325
+
326
+ let catalogSnapshot = readDiskCatalog() ?? cliDefaultCatalog()
327
+ let refreshPromise: Promise<CodexModelCatalog> | null = null
328
+ let periodicRefreshTimer: ReturnType<typeof setInterval> | null = null
329
+
330
+ export function getCodexModelCatalogSnapshot(): CodexModelCatalog {
331
+ return catalogSnapshot
332
+ }
333
+
334
+ /** Refresh with an injectable fetcher for alternate runtimes and tests. */
335
+ export async function refreshCodexModelCatalog(
336
+ fetcher: CodexCatalogFetcher = fetchAppServerModels,
337
+ ): Promise<CodexModelCatalog> {
338
+ try {
339
+ const models = await fetcher()
340
+ const refreshed = buildCodexModelCatalog(models, 'app-server')
341
+ if (refreshed.options.length === 0) throw new Error('No eligible GPT models')
342
+ catalogSnapshot = refreshed
343
+ } catch {
344
+ // Never downgrade a working app-server/disk catalog because one refresh
345
+ // failed. A fresh disk read is useful only when there is no known model id.
346
+ const hasKnownModel = catalogSnapshot.options.some(option => !!option.id)
347
+ const fallback = hasKnownModel ? catalogSnapshot : readDiskCatalog()
348
+ if (fallback) {
349
+ catalogSnapshot = {
350
+ ...fallback,
351
+ refreshError: 'Live Codex model discovery unavailable; retaining the last-known-good catalog.',
352
+ }
353
+ } else {
354
+ catalogSnapshot = cliDefaultCatalog('Live Codex model discovery unavailable; using the Codex CLI default.')
355
+ }
356
+ }
357
+ return catalogSnapshot
358
+ }
359
+
360
+ export async function getCodexModelCatalog(
361
+ forceRefresh = false,
362
+ fetcher?: CodexCatalogFetcher,
363
+ ): Promise<CodexModelCatalog> {
364
+ const parsedRefreshedAt = Date.parse(catalogSnapshot.refreshedAt)
365
+ const ageMs = Number.isFinite(parsedRefreshedAt) ? Date.now() - parsedRefreshedAt : Number.POSITIVE_INFINITY
366
+ if (!forceRefresh && catalogSnapshot.source === 'app-server' && ageMs < refreshTtlMs()) {
367
+ return catalogSnapshot
368
+ }
369
+ if (refreshPromise) return refreshPromise
370
+
371
+ refreshPromise = refreshCodexModelCatalog(fetcher).finally(() => {
372
+ refreshPromise = null
373
+ })
374
+ return refreshPromise
375
+ }
376
+
377
+ /** Start boot + periodic refresh. Idempotent; returned stop function aids tests. */
378
+ export function startCodexModelCatalogRefresh(options?: {
379
+ intervalMs?: number
380
+ refresh?: () => Promise<unknown>
381
+ }): () => void {
382
+ if (periodicRefreshTimer) return () => stopCodexModelCatalogRefresh()
383
+ const refresh = options?.refresh ?? (() => getCodexModelCatalog(true))
384
+ const configuredInterval = options?.intervalMs ?? refreshTtlMs()
385
+ const intervalMs = Math.max(MIN_PERIODIC_REFRESH_MS, configuredInterval)
386
+
387
+ void refresh().catch(() => { /* refresh function retains its own fallback */ })
388
+ periodicRefreshTimer = setInterval(() => {
389
+ void refresh().catch(() => { /* keep the prior catalog */ })
390
+ }, intervalMs)
391
+ periodicRefreshTimer.unref?.()
392
+ return () => stopCodexModelCatalogRefresh()
393
+ }
394
+
395
+ export function stopCodexModelCatalogRefresh(): void {
396
+ if (!periodicRefreshTimer) return
397
+ clearInterval(periodicRefreshTimer)
398
+ periodicRefreshTimer = null
399
+ }
400
+
401
+ export function resolveCodexModelOption(preference: CodexModelPreference): CodexModelOption {
402
+ const base = catalogSnapshot.options.find(option => option.preference === preference)
403
+ ?? catalogSnapshot.options[0]
404
+ ?? cliDefaultCatalog().options[0]
405
+ const configuredId = process.env.COS_CODEX_MODEL?.trim() || CODEX_MODEL_ID
406
+ if (preference === CODEX_FRONTIER_MODEL && configuredId) {
407
+ return {
408
+ ...base,
409
+ preference,
410
+ id: configuredId,
411
+ displayName: configuredId,
412
+ description: 'Explicit COS_CODEX_MODEL compatibility override for the legacy/frontier slot.',
413
+ supportedReasoningEfforts: [],
414
+ defaultReasoningEffort: resolveConfiguredCodexReasoningEffort(),
415
+ serviceTiers: [],
416
+ isDefault: true,
417
+ }
418
+ }
419
+ return base
420
+ }
421
+
422
+ export function resolveCodexPreferenceForModelId(modelId: string): CodexModelPreference | undefined {
423
+ const normalized = modelId.trim().toLowerCase()
424
+ return catalogSnapshot.options.find(option => option.id.toLowerCase() === normalized)?.preference
425
+ }
426
+
427
+ export function resolveCodexEffortForModel(
428
+ option: CodexModelOption,
429
+ effort: EffortPreference | undefined,
430
+ ): string {
431
+ const requested = effort === undefined && option.preference === CODEX_FRONTIER_MODEL
432
+ ? resolveConfiguredCodexReasoningEffort()
433
+ : resolveCodexReasoningEffort(effort)
434
+ const supported = new Set(option.supportedReasoningEfforts)
435
+ if (supported.size === 0 || supported.has(requested)) return requested
436
+
437
+ const fallbacks: Record<string, string[]> = {
438
+ ultra: ['max', 'xhigh', 'high', 'medium', 'low'],
439
+ max: ['xhigh', 'high', 'medium', 'low'],
440
+ xhigh: ['high', 'medium', 'low'],
441
+ high: ['medium', 'low'],
442
+ }
443
+ return (fallbacks[requested] ?? []).find(candidate => supported.has(candidate))
444
+ ?? option.defaultReasoningEffort
445
+ ?? 'high'
446
+ }
447
+
448
+ export function resolveCodexServiceTier(option: CodexModelOption): string | undefined {
449
+ return option.serviceTiers.includes(CODEX_SERVICE_TIER) ? CODEX_SERVICE_TIER : undefined
450
+ }
@@ -5,11 +5,15 @@ import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
5
  import { cosBrainDir } from './launch-dir.js'
6
6
  import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
7
7
  import {
8
+ CODEX_FRONTIER_MODEL,
8
9
  CODEX_HIGH_REASONING_EFFORT,
9
- CODEX_MODEL_ID,
10
10
  type CodexModelPreference,
11
11
  } from '../../shared/model-preference.js'
12
12
  import { dataPath } from './data-dir.js'
13
+ import {
14
+ getCodexModelCatalogSnapshot,
15
+ resolveCodexModelOption,
16
+ } from './codex-model-catalog.js'
13
17
 
14
18
  const DEFAULT_MAX_RUNS = 100
15
19
  const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
@@ -59,6 +63,8 @@ interface CodexRunEvent {
59
63
 
60
64
  export interface CodexRunConfig {
61
65
  cliModel: string
66
+ catalogSource: string
67
+ availableModels: Array<{ preference: CodexModelPreference; model: string; displayName: string }>
62
68
  reasoningEffort: string
63
69
  persistenceEnabled: boolean
64
70
  cwd: string
@@ -95,8 +101,16 @@ export function getCodexTrustMode(): CodexTrustMode {
95
101
  }
96
102
 
97
103
  export function getCodexRunConfig(): CodexRunConfig {
104
+ const catalog = getCodexModelCatalogSnapshot()
105
+ const frontier = resolveCodexModelOption(CODEX_FRONTIER_MODEL)
98
106
  return {
99
- cliModel: CODEX_MODEL_ID,
107
+ cliModel: frontier.id || 'codex-cli-default',
108
+ catalogSource: catalog.source,
109
+ availableModels: catalog.options.map(option => ({
110
+ preference: option.preference,
111
+ model: option.id || 'codex-cli-default',
112
+ displayName: option.displayName,
113
+ })),
100
114
  reasoningEffort: CODEX_HIGH_REASONING_EFFORT,
101
115
  persistenceEnabled: isCodexPersistenceEnabled(),
102
116
  cwd: getCodexExecutionCwd(),
@@ -233,6 +247,8 @@ export function startCodexRun(input: {
233
247
  codexThreadId?: string
234
248
  expiresAt?: string
235
249
  query: string
250
+ cliModel?: string
251
+ reasoningEffort?: string
236
252
  }): CodexRunRecord {
237
253
  const now = new Date().toISOString()
238
254
  const run: CodexRunRecord = {
@@ -242,8 +258,8 @@ export function startCodexRun(input: {
242
258
  createdAt: now,
243
259
  updatedAt: now,
244
260
  model: input.model,
245
- cliModel: CODEX_MODEL_ID,
246
- reasoningEffort: CODEX_HIGH_REASONING_EFFORT,
261
+ cliModel: input.cliModel ?? (resolveCodexModelOption(input.model).id || 'codex-cli-default'),
262
+ reasoningEffort: input.reasoningEffort ?? CODEX_HIGH_REASONING_EFFORT,
247
263
  cwd: input.cwd,
248
264
  ephemeral: input.ephemeral,
249
265
  resumed: input.resumed,
@@ -13,6 +13,7 @@ import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-
13
13
  import { atomicWriteFileSync, 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
+ import { parseMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
16
17
 
17
18
  export type { ModelPreference }
18
19
 
@@ -21,6 +22,10 @@ export interface Exchange {
21
22
  content: string
22
23
  timestamp: number
23
24
  globalMsgNum?: number // Client's global message number for this Q&A pair
25
+ /** Public attachment refs may live on either half of a Q&A pair: request
26
+ * photos on the user exchange, model-published images on the assistant
27
+ * exchange. Bytes, filesystem paths, and capabilities never persist here. */
28
+ attachments?: MediaAttachmentRef[]
24
29
  }
25
30
 
26
31
  interface Session {
@@ -73,6 +78,15 @@ function loadFromDisk(): void {
73
78
  for (const [id, session] of Object.entries(result.data.sessions)) {
74
79
  if (!session.contextBreaks) session.contextBreaks = []
75
80
  session.modelPreference = normalizeModelPreference(session.modelPreference) ?? null
81
+ // Persistence boundary: malformed refs are dropped without sacrificing
82
+ // the surrounding exchange or the rest of the recovered session.
83
+ for (const exchange of session.exchanges) {
84
+ if ('attachments' in exchange && exchange.attachments !== undefined) {
85
+ const refs = parseMediaAttachmentRefs(exchange.attachments)
86
+ if (refs.length > 0) exchange.attachments = refs
87
+ else delete exchange.attachments
88
+ }
89
+ }
76
90
  sessions.set(id, session)
77
91
  loaded++
78
92
  }
@@ -296,14 +310,27 @@ export function replaceLastExchangeWithSummary(
296
310
  scheduleCacheUpdate()
297
311
  }
298
312
 
299
- export function addExchange(sessionId: string, role: 'user' | 'assistant', content: string, globalMsgNum?: number): void {
313
+ export function addExchange(
314
+ sessionId: string,
315
+ role: 'user' | 'assistant',
316
+ content: string,
317
+ globalMsgNum?: number,
318
+ attachments?: MediaAttachmentRef[],
319
+ ): Exchange {
300
320
  let session = sessions.get(sessionId)
301
321
  if (!session) {
302
322
  session = { id: sessionId, exchanges: [], lastActivity: Date.now(), createdAt: Date.now(), modelPreference: null, contextBreaks: [] }
303
323
  sessions.set(sessionId, session)
304
324
  }
305
325
 
306
- session.exchanges.push({ role, content, timestamp: Date.now(), globalMsgNum })
326
+ const exchange: Exchange = {
327
+ role,
328
+ content,
329
+ timestamp: Date.now(),
330
+ globalMsgNum,
331
+ ...(attachments && attachments.length > 0 ? { attachments } : {}),
332
+ }
333
+ session.exchanges.push(exchange)
307
334
  session.lastActivity = Date.now()
308
335
 
309
336
  // Trim to rolling buffer
@@ -313,6 +340,41 @@ export function addExchange(sessionId: string, role: 'user' | 'assistant', conte
313
340
 
314
341
  scheduleSave()
315
342
  scheduleCacheUpdate()
343
+ return exchange
344
+ }
345
+
346
+ /** Attach validated refs to an already-persisted exchange. Bridges save the
347
+ * assistant text first, then use this after slower output-image preparation.
348
+ * Object identity prevents a concurrent turn from receiving the refs. */
349
+ export function setExchangeAttachments(
350
+ sessionId: string,
351
+ exchange: Exchange,
352
+ attachments: unknown,
353
+ ): boolean {
354
+ const session = sessions.get(sessionId)
355
+ if (!session || !session.exchanges.includes(exchange)) return false
356
+ const refs = parseMediaAttachmentRefs(attachments)
357
+ if (refs.length > 0) exchange.attachments = refs
358
+ else delete exchange.attachments
359
+ session.lastActivity = Date.now()
360
+ scheduleSave()
361
+ scheduleCacheUpdate()
362
+ return true
363
+ }
364
+
365
+ /** Remove exactly the exchange object returned by addExchange.
366
+ * Identity matching prevents a failed duplicate prompt from deleting an older,
367
+ * byte-identical turn in the same conversation. */
368
+ export function removeExchange(sessionId: string, exchange: Exchange): boolean {
369
+ const session = sessions.get(sessionId)
370
+ if (!session) return false
371
+ const index = session.exchanges.indexOf(exchange)
372
+ if (index < 0) return false
373
+ session.exchanges.splice(index, 1)
374
+ session.lastActivity = Date.now()
375
+ scheduleSave()
376
+ scheduleCacheUpdate()
377
+ return true
316
378
  }
317
379
 
318
380
  /** Clear a session — archive + log BEFORE deleting from the live Map.
@@ -2,6 +2,7 @@
2
2
  // to all connected glasses clients (SSE display-stream subscribers)
3
3
 
4
4
  import { EventEmitter } from 'node:events'
5
+ import { serverMetrics } from './server-metrics.js'
5
6
 
6
7
  const bus = new EventEmitter()
7
8
  bus.setMaxListeners(20) // Multiple glasses clients
@@ -11,11 +12,68 @@ export interface DisplayEvent {
11
12
  data: Record<string, unknown>
12
13
  }
13
14
 
14
- export function emitDisplay(event: DisplayEvent): void {
15
- bus.emit('display', event)
15
+ export interface PublishedDisplayEvent extends DisplayEvent {
16
+ bootId: string
17
+ eventId: number
18
+ publishedAt: string
16
19
  }
17
20
 
18
- export function onDisplay(listener: (event: DisplayEvent) => void): () => void {
21
+ export interface DisplayReplayResult {
22
+ events: PublishedDisplayEvent[]
23
+ gap: boolean
24
+ reason?: 'boot_changed' | 'cursor_ahead' | 'buffer_overflow'
25
+ oldestEventId: number
26
+ latestEventId: number
27
+ }
28
+
29
+ const REPLAY_BUFFER_SIZE = 200
30
+ let eventId = 0
31
+ const replayBuffer: PublishedDisplayEvent[] = []
32
+
33
+ export function emitDisplay(event: DisplayEvent): PublishedDisplayEvent {
34
+ const published: PublishedDisplayEvent = {
35
+ ...event,
36
+ bootId: serverMetrics.bootId,
37
+ eventId: ++eventId,
38
+ publishedAt: new Date().toISOString(),
39
+ }
40
+ replayBuffer.push(published)
41
+ if (replayBuffer.length > REPLAY_BUFFER_SIZE) replayBuffer.shift()
42
+ bus.emit('display', published)
43
+ return published
44
+ }
45
+
46
+ export function onDisplay(listener: (event: PublishedDisplayEvent) => void): () => void {
19
47
  bus.on('display', listener)
20
48
  return () => { bus.off('display', listener) }
21
49
  }
50
+
51
+ export function getDisplayWatermark(): { bootId: string; eventId: number } {
52
+ return { bootId: serverMetrics.bootId, eventId }
53
+ }
54
+
55
+ export function replayDisplayEvents(bootId: string | null, afterEventId: number): DisplayReplayResult {
56
+ const oldestEventId = replayBuffer[0]?.eventId ?? eventId + 1
57
+ const latestEventId = eventId
58
+ if (bootId && bootId !== serverMetrics.bootId) {
59
+ return { events: [], gap: true, reason: 'boot_changed', oldestEventId, latestEventId }
60
+ }
61
+ if (afterEventId > latestEventId) {
62
+ return { events: [], gap: true, reason: 'cursor_ahead', oldestEventId, latestEventId }
63
+ }
64
+ if (afterEventId > 0 && afterEventId < oldestEventId - 1) {
65
+ return { events: [], gap: true, reason: 'buffer_overflow', oldestEventId, latestEventId }
66
+ }
67
+ return {
68
+ events: replayBuffer.filter(item => item.eventId > afterEventId),
69
+ gap: false,
70
+ oldestEventId,
71
+ latestEventId,
72
+ }
73
+ }
74
+
75
+ export function __resetDisplayBusForTests(): void {
76
+ eventId = 0
77
+ replayBuffer.splice(0)
78
+ bus.removeAllListeners('display')
79
+ }