@gotcos/glasses-server 6.1.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 (51) hide show
  1. package/.cos-profile.example.json +7 -0
  2. package/.env.example +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/LICENSE +21 -0
  5. package/README.md +78 -0
  6. package/bin/cli.cjs +203 -0
  7. package/package.json +53 -0
  8. package/server/env.ts +26 -0
  9. package/server/index.ts +211 -0
  10. package/server/lib/archive-budget.ts +65 -0
  11. package/server/lib/archive.ts +414 -0
  12. package/server/lib/atomic-fs.ts +50 -0
  13. package/server/lib/audio-enhance.ts +87 -0
  14. package/server/lib/claude-bridge.ts +682 -0
  15. package/server/lib/claude-circuit.ts +52 -0
  16. package/server/lib/claude-run-ledger.ts +279 -0
  17. package/server/lib/codex-bridge.ts +476 -0
  18. package/server/lib/codex-engine-sessions.ts +140 -0
  19. package/server/lib/codex-run-ledger.ts +298 -0
  20. package/server/lib/context-builder.ts +210 -0
  21. package/server/lib/conversation.ts +587 -0
  22. package/server/lib/data-dir.ts +20 -0
  23. package/server/lib/display-bus.ts +21 -0
  24. package/server/lib/display-format.ts +23 -0
  25. package/server/lib/fuzzy-correct.ts +286 -0
  26. package/server/lib/hallucination-filter.ts +469 -0
  27. package/server/lib/local-day.ts +13 -0
  28. package/server/lib/model-router.ts +38 -0
  29. package/server/lib/openai-key.ts +155 -0
  30. package/server/lib/openai-whisper-budget.ts +170 -0
  31. package/server/lib/profile.ts +94 -0
  32. package/server/lib/python-bridge.ts +84 -0
  33. package/server/lib/response-cache.ts +138 -0
  34. package/server/lib/session-cache-writer.ts +266 -0
  35. package/server/lib/session-log.ts +162 -0
  36. package/server/lib/speaker-embeddings.ts +578 -0
  37. package/server/lib/telegram-notify.ts +85 -0
  38. package/server/lib/token-audit.ts +50 -0
  39. package/server/lib/transcribe-audio.ts +187 -0
  40. package/server/lib/utils.ts +5 -0
  41. package/server/lib/vad-silero.ts +179 -0
  42. package/server/lib/whisper-local.ts +697 -0
  43. package/server/routes/diag.ts +115 -0
  44. package/server/routes/display.ts +65 -0
  45. package/server/routes/health.ts +128 -0
  46. package/server/routes/openai-compat.ts +446 -0
  47. package/server/routes/openai-key.ts +121 -0
  48. package/server/routes/query.ts +121 -0
  49. package/server/routes/transcribe-stream.ts +1090 -0
  50. package/server/routes/transcribe.ts +55 -0
  51. package/shared/model-preference.ts +81 -0
@@ -0,0 +1,476 @@
1
+ // Codex bridge — streaming-compatible interface to `codex exec --json`
2
+ // MVP target: local subscription-authenticated GPT-5.5 High via Codex CLI.
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { writeFileSync, unlinkSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import crypto from 'node:crypto'
8
+ import { logTokenAudit } from './token-audit.js'
9
+ import { buildSystemPrompt, buildLightweightSystemPrompt } from './context-builder.js'
10
+ import {
11
+ getHistory,
12
+ addExchange,
13
+ formatHistoryForPrompt,
14
+ getOrCreateSession,
15
+ isNewSession,
16
+ markSessionNotified,
17
+ getSessionRaw,
18
+ replaceLastExchangeWithSummary,
19
+ type PromptReference,
20
+ } from './conversation.js'
21
+ import { notifySessionStart, notifyExchange } from './telegram-notify.js'
22
+ import {
23
+ clearCodexEngineSession,
24
+ getCodexEngineSession,
25
+ saveCodexEngineSession,
26
+ type CodexEngineSession,
27
+ } from './codex-engine-sessions.js'
28
+ import {
29
+ CODEX_HIGH_MODEL,
30
+ CODEX_HIGH_REASONING_EFFORT,
31
+ CODEX_MODEL_ID,
32
+ type CodexModelPreference,
33
+ } from '../../shared/model-preference.js'
34
+ import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
35
+ import {
36
+ classifyCodexError,
37
+ extractCodexThreadId,
38
+ finishCodexRun,
39
+ getCodexExecutionCwd,
40
+ isCodexPersistenceEnabled,
41
+ getCodexTrustMode,
42
+ startCodexRun,
43
+ updateCodexRun,
44
+ type CodexRunStatus,
45
+ } from './codex-run-ledger.js'
46
+
47
+ const INACTIVITY_MS = 180_000
48
+ const WALL_MAX_MS = 900_000
49
+ const HEARTBEAT_INTERVAL_MS = 6_000
50
+
51
+ type Phase = 'context' | 'thinking' | 'generating'
52
+
53
+ const PHASE_LABELS: Record<Phase, string> = {
54
+ context: 'Loading context...',
55
+ thinking: 'Reasoning...',
56
+ generating: 'Writing...',
57
+ }
58
+
59
+ // Sandbox policy for `codex exec`. This public server NEVER runs codex
60
+ // unsandboxed — that would let a remote glasses query execute arbitrary commands
61
+ // on the host. Default: read-only (safe for chat). COS_CODEX_SANDBOX=workspace-write
62
+ // permits writes within the working directory only. Full host access is
63
+ // intentionally not exposed by this server.
64
+ function codexSandboxArgs(): string[] {
65
+ const mode = process.env.COS_CODEX_SANDBOX === 'workspace-write' ? 'workspace-write' : 'read-only'
66
+ return ['--sandbox', mode, '--skip-git-repo-check']
67
+ }
68
+
69
+ export function buildCodexExecArgs(input: {
70
+ codexCwd: string
71
+ imagePaths?: string[]
72
+ persistentCodexSession: boolean
73
+ codexThreadId?: string
74
+ }): string[] {
75
+ const imagePaths = input.imagePaths ?? []
76
+ const args = ['exec']
77
+ if (input.codexThreadId) {
78
+ args.push(
79
+ 'resume',
80
+ '--json',
81
+ '--all',
82
+ ...codexSandboxArgs(),
83
+ '-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
84
+ )
85
+ if (CODEX_MODEL_ID) args.push('--model', CODEX_MODEL_ID)
86
+ for (const p of imagePaths) args.push('--image', p)
87
+ args.push(input.codexThreadId, '-')
88
+ return args
89
+ }
90
+
91
+ args.push(
92
+ '--json',
93
+ '--cd', input.codexCwd,
94
+ ...codexSandboxArgs(),
95
+ '-c', `model_reasoning_effort="${CODEX_HIGH_REASONING_EFFORT}"`,
96
+ )
97
+ if (CODEX_MODEL_ID) args.push('--model', CODEX_MODEL_ID)
98
+ if (!input.persistentCodexSession) args.push('--ephemeral')
99
+ for (const p of imagePaths) args.push('--image', p)
100
+ args.push('-')
101
+ return args
102
+ }
103
+
104
+ function buildCodexPrompt(systemPrompt: string, fullQuery: string): string {
105
+ return [
106
+ 'SYSTEM INSTRUCTIONS',
107
+ systemPrompt,
108
+ '',
109
+ 'USER REQUEST',
110
+ fullQuery,
111
+ ].join('\n')
112
+ }
113
+
114
+ function eventText(event: any): string {
115
+ const item = event?.item ?? event?.payload ?? event?.message ?? event
116
+
117
+ if (typeof event?.delta === 'string') return event.delta
118
+ if (typeof event?.text === 'string' && /message|delta|answer/i.test(String(event.type ?? ''))) return event.text
119
+ if (typeof item?.text === 'string' && /agent_message|message|assistant/i.test(String(item.type ?? event?.type ?? ''))) return item.text
120
+
121
+ const content = item?.content ?? event?.content
122
+ if (Array.isArray(content)) {
123
+ let text = ''
124
+ for (const block of content) {
125
+ if (typeof block === 'string') text += block
126
+ if (typeof block?.text === 'string') text += block.text
127
+ if (typeof block?.content === 'string') text += block.content
128
+ if (typeof block?.output_text === 'string') text += block.output_text
129
+ }
130
+ return text
131
+ }
132
+
133
+ if (typeof item?.result === 'string' && /result|completed|answer/i.test(String(event?.type ?? ''))) return item.result
134
+ return ''
135
+ }
136
+
137
+ function toolStatus(event: any): string | undefined {
138
+ const type = String(event?.type ?? '')
139
+ const item = event?.item ?? event?.payload
140
+ const itemType = String(item?.type ?? '')
141
+ const name = item?.name ?? item?.tool_name
142
+
143
+ if (type === 'thread.started') return 'Starting Codex...'
144
+ if (type === 'turn.started') return 'Reasoning...'
145
+ if (/patch/i.test(type) || /patch/i.test(itemType)) return 'Applying patch...'
146
+ if (/command|exec|shell/i.test(type) || /command|exec|shell/i.test(itemType)) return 'Using shell...'
147
+ if (/tool/i.test(type) || /tool/i.test(itemType)) {
148
+ if (typeof name === 'string' && /^[A-Za-z0-9_.:-]{1,24}$/.test(name)) return name
149
+ return 'Using tool...'
150
+ }
151
+ return undefined
152
+ }
153
+
154
+ function safeCodexUserError(message: string): string {
155
+ const code = classifyCodexError(message)
156
+ if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
157
+ if (code === 'codex.auth_error') return 'Codex auth failed. Run codex login on the Mac.'
158
+ if (code === 'codex.timeout') return 'Codex timed out. Retry or start a new chat.'
159
+ if (code === 'codex.permission_denied') return 'Codex permission failed. Check full-access configuration.'
160
+ return `Codex failed (${code}). Retry or check Codex Debug.`
161
+ }
162
+
163
+ export async function callCodexStreaming(
164
+ query: string,
165
+ sessionId: string | undefined,
166
+ callbacks: StreamCallbacks,
167
+ model: CodexModelPreference = CODEX_HIGH_MODEL,
168
+ images?: string[],
169
+ reference?: PromptReference,
170
+ globalMsgNum?: number,
171
+ options?: CallOptions,
172
+ ): Promise<string> {
173
+ const sid = getOrCreateSession(sessionId)
174
+ const history = getHistory(sid)
175
+ const session = getSessionRaw(sid)
176
+ const contextBreaks = session?.contextBreaks ?? []
177
+ const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
178
+ const contextPrompt = historyPrompt
179
+ const persistentCodexSession = isCodexPersistenceEnabled()
180
+ const codexCwd = getCodexExecutionCwd()
181
+ const codexTrustMode = getCodexTrustMode()
182
+ const engineSession = persistentCodexSession
183
+ ? getCodexEngineSession({ cosSessionId: sid, model, cwd: codexCwd, trustMode: codexTrustMode })
184
+ : null
185
+ const startTime = Date.now()
186
+ const run = startCodexRun({
187
+ cosSessionId: sid,
188
+ model,
189
+ cwd: codexCwd,
190
+ ephemeral: !persistentCodexSession,
191
+ resumed: !!engineSession,
192
+ trustMode: codexTrustMode,
193
+ codexThreadId: engineSession?.codexThreadId,
194
+ expiresAt: engineSession?.expiresAt,
195
+ query,
196
+ })
197
+ let codexThreadId: string | undefined = engineSession?.codexThreadId
198
+ callbacks.onStart?.(model, sid, undefined, { codexRunId: run.runId, codexThreadId })
199
+
200
+ let phase: Phase = 'context'
201
+ let systemPrompt: string
202
+ try {
203
+ if (options?.lightweight) {
204
+ systemPrompt = buildLightweightSystemPrompt(query, contextPrompt)
205
+ } else {
206
+ callbacks.onToolStatus?.('Loading context...')
207
+ systemPrompt = await buildSystemPrompt(contextPrompt)
208
+ }
209
+ } catch (err: any) {
210
+ finishCodexRun(run.runId, {
211
+ status: 'failed',
212
+ startedAtMs: startTime,
213
+ error: `codex-bridge: context build failed — ${err?.message ?? 'unknown error'}`,
214
+ exitCode: null,
215
+ })
216
+ throw err
217
+ }
218
+
219
+ phase = 'thinking'
220
+ callbacks.onToolStatus?.('Reasoning...')
221
+
222
+ const imagePaths: string[] = []
223
+ try {
224
+ if (images && images.length > 0) {
225
+ for (const img of images) {
226
+ const id = crypto.randomUUID().slice(0, 8)
227
+ const p = join('/tmp', `cos-vision-${id}.jpg`)
228
+ writeFileSync(p, Buffer.from(img, 'base64'))
229
+ imagePaths.push(p)
230
+ }
231
+ }
232
+ } catch (err: any) {
233
+ for (const p of imagePaths) {
234
+ try { unlinkSync(p) } catch { /* ignore */ }
235
+ }
236
+ finishCodexRun(run.runId, {
237
+ status: 'failed',
238
+ startedAtMs: startTime,
239
+ error: `codex-bridge: image staging failed — ${err?.message ?? 'unknown error'}`,
240
+ exitCode: null,
241
+ })
242
+ throw err
243
+ }
244
+
245
+ const isFirstQuery = isNewSession(sid)
246
+ const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
247
+ const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
248
+ addExchange(sid, 'user', historyQuery, globalMsgNum)
249
+
250
+ let fullQuery: string
251
+ if (imagePaths.length === 1) {
252
+ fullQuery = `The user has shared a photo from their phone camera. Use the attached image, then respond to their request: ${query || 'Describe what you see in this image concisely.'}`
253
+ } else if (imagePaths.length > 1) {
254
+ fullQuery = `The user has shared ${imagePaths.length} photos from their phone camera. Use the attached images, then respond to their request: ${query || 'Describe what you see in these images concisely.'}`
255
+ } else {
256
+ fullQuery = query
257
+ }
258
+
259
+ const prompt = buildCodexPrompt(systemPrompt, fullQuery)
260
+ const args = buildCodexExecArgs({
261
+ codexCwd,
262
+ imagePaths,
263
+ persistentCodexSession,
264
+ codexThreadId: engineSession?.codexThreadId,
265
+ })
266
+
267
+ const env = { ...process.env }
268
+ delete env.CLAUDECODE
269
+
270
+ const proc = spawn('codex', args, {
271
+ stdio: ['pipe', 'pipe', 'pipe'],
272
+ env,
273
+ cwd: codexCwd,
274
+ })
275
+
276
+ let fullText = ''
277
+ let stderr = ''
278
+ let buffer = ''
279
+ let finalized = false
280
+ let lastActivity = Date.now()
281
+ const emittedBlocks = new Set<string>()
282
+
283
+ function cleanupImages() {
284
+ for (const p of imagePaths) {
285
+ try { unlinkSync(p) } catch { /* ignore */ }
286
+ }
287
+ }
288
+
289
+ function cleanup() {
290
+ clearInterval(heartbeat)
291
+ clearTimeout(inactivityTimer)
292
+ clearTimeout(wallTimer)
293
+ options?.abortSignal?.removeEventListener('abort', handleAbort)
294
+ }
295
+
296
+ function emitText(text: string) {
297
+ if (!text || emittedBlocks.has(text)) return
298
+ emittedBlocks.add(text)
299
+ phase = 'generating'
300
+ fullText += text
301
+ callbacks.onChunk(text)
302
+ }
303
+
304
+ function finalize(text: string) {
305
+ if (finalized) return
306
+ finalized = true
307
+ cleanup()
308
+ cleanupImages()
309
+
310
+ const totalMs = Date.now() - startTime
311
+ logTokenAudit({
312
+ source: options?.lightweight ? 'g2-voice' : 'g2-query',
313
+ model,
314
+ inputChars: systemPrompt.length + fullQuery.length + contextPrompt.length,
315
+ outputChars: text.length,
316
+ durationMs: totalMs,
317
+ caller: options?.lightweight ? 'voice_query' : 'full_query',
318
+ })
319
+ if (persistentCodexSession && codexThreadId) {
320
+ const saved = saveCodexEngineSession({
321
+ cosSessionId: sid,
322
+ model,
323
+ codexThreadId,
324
+ cwd: codexCwd,
325
+ trustMode: codexTrustMode,
326
+ })
327
+ updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
328
+ }
329
+
330
+ finishCodexRun(run.runId, {
331
+ status: 'completed',
332
+ startedAtMs: startTime,
333
+ output: text,
334
+ exitCode: 0,
335
+ })
336
+ addExchange(sid, 'assistant', text, globalMsgNum)
337
+ if (imagePaths.length > 0) {
338
+ replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
339
+ }
340
+
341
+ callbacks.onDone(text, model, undefined, { codexRunId: run.runId, codexThreadId })
342
+
343
+ if (isFirstQuery) {
344
+ notifySessionStart(sid, query)
345
+ markSessionNotified(sid)
346
+ }
347
+ notifyExchange(sid, query, text)
348
+ }
349
+
350
+ function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
351
+ if (finalized) return
352
+ finalized = true
353
+ cleanup()
354
+ cleanupImages()
355
+ if (engineSession) {
356
+ clearCodexEngineSession(sid, model)
357
+ }
358
+ finishCodexRun(run.runId, {
359
+ status,
360
+ startedAtMs: startTime,
361
+ error: msg,
362
+ exitCode,
363
+ })
364
+ callbacks.onError(safeCodexUserError(msg))
365
+ }
366
+
367
+ function handleAbort() {
368
+ if (finalized) return
369
+ proc.kill('SIGTERM')
370
+ finalizeError('codex-bridge: client disconnected before Codex completed.', null, 'client_disconnected')
371
+ }
372
+
373
+ const heartbeat = setInterval(() => {
374
+ if (finalized) return
375
+ callbacks.onToolStatus?.(PHASE_LABELS[phase] ?? 'Processing...')
376
+ }, HEARTBEAT_INTERVAL_MS)
377
+
378
+ let inactivityTimer = setTimeout(() => {
379
+ proc.kill('SIGTERM')
380
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
381
+ finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`)
382
+ }, INACTIVITY_MS)
383
+
384
+ function resetInactivity() {
385
+ lastActivity = Date.now()
386
+ clearTimeout(inactivityTimer)
387
+ inactivityTimer = setTimeout(() => {
388
+ proc.kill('SIGTERM')
389
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
390
+ finalizeError(`No output for ${INACTIVITY_MS / 1000}s (${elapsed}s total). Codex process killed.`)
391
+ }, INACTIVITY_MS)
392
+ }
393
+
394
+ const wallTimer = setTimeout(() => {
395
+ proc.kill('SIGTERM')
396
+ if (fullText) {
397
+ finalize(fullText)
398
+ } else {
399
+ finalizeError(`Wall clock limit reached (${WALL_MAX_MS / 1000}s). Codex process killed.`)
400
+ }
401
+ }, WALL_MAX_MS)
402
+
403
+ if (options?.abortSignal) {
404
+ if (options.abortSignal.aborted) {
405
+ handleAbort()
406
+ } else {
407
+ options.abortSignal.addEventListener('abort', handleAbort, { once: true })
408
+ }
409
+ }
410
+
411
+ function handleEvent(event: any) {
412
+ const nextThreadId = extractCodexThreadId(event)
413
+ if (nextThreadId && nextThreadId !== codexThreadId) {
414
+ codexThreadId = nextThreadId
415
+ updateCodexRun(run.runId, { codexThreadId })
416
+ }
417
+
418
+ const status = toolStatus(event)
419
+ if (status) callbacks.onToolStatus?.(status)
420
+
421
+ const text = eventText(event)
422
+ if (text) emitText(text)
423
+
424
+ const type = String(event?.type ?? '')
425
+ if (type === 'turn.completed') {
426
+ finalize(fullText)
427
+ } else if (type === 'turn.failed' || type === 'error') {
428
+ finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
429
+ }
430
+ }
431
+
432
+ proc.stdout.on('data', (chunk: Buffer) => {
433
+ resetInactivity()
434
+ buffer += chunk.toString()
435
+ const lines = buffer.split('\n')
436
+ buffer = lines.pop() ?? ''
437
+
438
+ for (const line of lines) {
439
+ const trimmed = line.trim()
440
+ if (!trimmed) continue
441
+ try {
442
+ handleEvent(JSON.parse(trimmed))
443
+ } catch {
444
+ // Ignore non-JSON status lines from older CLI builds.
445
+ }
446
+ }
447
+ })
448
+
449
+ proc.stderr.on('data', (chunk: Buffer) => {
450
+ resetInactivity()
451
+ stderr += chunk.toString()
452
+ })
453
+
454
+ proc.on('close', (code) => {
455
+ if (buffer.trim()) {
456
+ try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
457
+ }
458
+ if (finalized) return
459
+ if (code !== 0) {
460
+ finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
461
+ } else if (fullText) {
462
+ finalize(fullText)
463
+ } else {
464
+ finalizeError('codex-bridge: Codex completed without a response.')
465
+ }
466
+ })
467
+
468
+ proc.on('error', (err) => {
469
+ finalizeError(`codex-bridge: ${err.message}`)
470
+ })
471
+
472
+ proc.stdin.write(prompt)
473
+ proc.stdin.end()
474
+
475
+ return sid
476
+ }
@@ -0,0 +1,140 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import type { CodexModelPreference } from '../../shared/model-preference.js'
4
+
5
+ export const CODEX_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
6
+
7
+ export type CodexTrustMode = 'read-only' | 'workspace-write'
8
+
9
+ export interface CodexEngineSession {
10
+ key: string
11
+ cosSessionId: string
12
+ model: CodexModelPreference
13
+ codexThreadId: string
14
+ cwd: string
15
+ trustMode: CodexTrustMode
16
+ savedAt: number
17
+ lastUsedAt: number
18
+ expiresAt: string
19
+ }
20
+
21
+ interface CodexEngineSessionFile {
22
+ sessions: Record<string, CodexEngineSession>
23
+ savedAt: string
24
+ }
25
+
26
+ function sessionKey(cosSessionId: string, model: CodexModelPreference): string {
27
+ return `${cosSessionId}:${model}`
28
+ }
29
+
30
+ export function getCodexEngineSessionPath(): string {
31
+ return resolve(process.env.COS_CODEX_ENGINE_SESSIONS_FILE || '/tmp/cos-codex-engine-sessions.json')
32
+ }
33
+
34
+ function expiresAtFrom(now: number): string {
35
+ return new Date(now + CODEX_ENGINE_SESSION_TTL_MS).toISOString()
36
+ }
37
+
38
+ function readStore(): CodexEngineSessionFile {
39
+ const path = getCodexEngineSessionPath()
40
+ if (!existsSync(path)) return { sessions: {}, savedAt: new Date().toISOString() }
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<CodexEngineSessionFile>
43
+ return {
44
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
45
+ savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : new Date().toISOString(),
46
+ }
47
+ } catch {
48
+ return { sessions: {}, savedAt: new Date().toISOString() }
49
+ }
50
+ }
51
+
52
+ function writeStore(store: CodexEngineSessionFile): void {
53
+ const path = getCodexEngineSessionPath()
54
+ mkdirSync(dirname(path), { recursive: true })
55
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`
56
+ writeFileSync(tmp, JSON.stringify(store, null, 2))
57
+ renameSync(tmp, path)
58
+ }
59
+
60
+ function pruneExpired(sessions: Record<string, CodexEngineSession>, now = Date.now()): Record<string, CodexEngineSession> {
61
+ const next: Record<string, CodexEngineSession> = {}
62
+ for (const [key, session] of Object.entries(sessions)) {
63
+ const expiresMs = Date.parse(session.expiresAt)
64
+ if (Number.isFinite(expiresMs) && expiresMs > now) next[key] = session
65
+ }
66
+ return next
67
+ }
68
+
69
+ export function getCodexEngineSession(input: {
70
+ cosSessionId: string
71
+ model: CodexModelPreference
72
+ cwd: string
73
+ trustMode: CodexTrustMode
74
+ }): CodexEngineSession | null {
75
+ const store = readStore()
76
+ const now = Date.now()
77
+ const sessions = pruneExpired(store.sessions, now)
78
+ const existing = sessions[sessionKey(input.cosSessionId, input.model)]
79
+ if (!existing) {
80
+ if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
81
+ writeStore({ sessions, savedAt: new Date().toISOString() })
82
+ }
83
+ return null
84
+ }
85
+ if (existing.cwd !== input.cwd || existing.trustMode !== input.trustMode) return null
86
+ return existing
87
+ }
88
+
89
+ export function saveCodexEngineSession(input: {
90
+ cosSessionId: string
91
+ model: CodexModelPreference
92
+ codexThreadId: string
93
+ cwd: string
94
+ trustMode: CodexTrustMode
95
+ now?: number
96
+ }): CodexEngineSession {
97
+ const now = input.now ?? Date.now()
98
+ const store = readStore()
99
+ const sessions = pruneExpired(store.sessions, now)
100
+ const key = sessionKey(input.cosSessionId, input.model)
101
+ const previous = sessions[key]
102
+ const session: CodexEngineSession = {
103
+ key,
104
+ cosSessionId: input.cosSessionId,
105
+ model: input.model,
106
+ codexThreadId: input.codexThreadId,
107
+ cwd: input.cwd,
108
+ trustMode: input.trustMode,
109
+ savedAt: previous?.savedAt ?? now,
110
+ lastUsedAt: now,
111
+ expiresAt: expiresAtFrom(now),
112
+ }
113
+ sessions[key] = session
114
+ writeStore({ sessions, savedAt: new Date().toISOString() })
115
+ return session
116
+ }
117
+
118
+ export function clearCodexEngineSession(cosSessionId: string, model?: CodexModelPreference): number {
119
+ const store = readStore()
120
+ const sessions = pruneExpired(store.sessions)
121
+ let removed = 0
122
+ for (const key of Object.keys(sessions)) {
123
+ const session = sessions[key]
124
+ if (session.cosSessionId !== cosSessionId) continue
125
+ if (model && session.model !== model) continue
126
+ delete sessions[key]
127
+ removed += 1
128
+ }
129
+ if (removed > 0) writeStore({ sessions, savedAt: new Date().toISOString() })
130
+ return removed
131
+ }
132
+
133
+ export function listCodexEngineSessions(): CodexEngineSession[] {
134
+ const store = readStore()
135
+ const sessions = pruneExpired(store.sessions)
136
+ if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
137
+ writeStore({ sessions, savedAt: new Date().toISOString() })
138
+ }
139
+ return Object.values(sessions).sort((a, b) => b.lastUsedAt - a.lastUsedAt)
140
+ }