@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,324 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Capability-scoped image publisher used by a single COS Glasses model run.
4
+ // It copies one already-local supported image artifact into the run's private inbox and
5
+ // appends only an opaque content id + generic provenance to the manifest.
6
+ // Source paths, URLs, and bytes never enter the manifest or stdout.
7
+
8
+ import { createHash, timingSafeEqual } from 'node:crypto'
9
+ import {
10
+ chmodSync,
11
+ closeSync,
12
+ constants,
13
+ existsSync,
14
+ fstatSync,
15
+ fsyncSync,
16
+ lstatSync,
17
+ mkdirSync,
18
+ openSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ renameSync,
22
+ rmSync,
23
+ statSync,
24
+ writeFileSync,
25
+ writeSync,
26
+ } from 'node:fs'
27
+ import { basename, isAbsolute, join, sep } from 'node:path'
28
+
29
+ const RUN_DIR_PREFIX = 'cos-glasses-output-images-'
30
+ const ABSOLUTE_MAX_IMAGES = 5
31
+ const MAX_IMAGE_BYTES = 16 * 1024 * 1024
32
+ const MANIFEST_MAX_BYTES = 64 * 1024
33
+ const PROVENANCE = new Set(['generated', 'research', 'email'])
34
+ const OUTPUT_ID_RE = /^o_[a-f0-9]{32}$/
35
+ const WAIT_ARRAY = new Int32Array(new SharedArrayBuffer(4))
36
+
37
+ function configuredMaxImages() {
38
+ const parsed = Number(process.env.COS_OUTPUT_IMAGE_MAX)
39
+ if (!Number.isInteger(parsed)) return ABSOLUTE_MAX_IMAGES
40
+ return Math.max(0, Math.min(ABSOLUTE_MAX_IMAGES, parsed))
41
+ }
42
+
43
+ function fail(message) {
44
+ throw new Error(message)
45
+ }
46
+
47
+ function sleep(ms) {
48
+ Atomics.wait(WAIT_ARRAY, 0, 0, ms)
49
+ }
50
+
51
+ function isSupportedMagic(bytes) {
52
+ const jpeg = bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
53
+ const png = bytes.length >= 8 &&
54
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
55
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a
56
+ const webp = bytes.length >= 12 &&
57
+ bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP'
58
+ let isoImage = false
59
+ if (bytes.length >= 12 && bytes.toString('ascii', 4, 8) === 'ftyp') {
60
+ const declaredBoxSize = bytes.readUInt32BE(0)
61
+ const boxEnd = Math.min(bytes.length, declaredBoxSize >= 16 ? declaredBoxSize : 16, 256)
62
+ const brands = [bytes.toString('ascii', 8, 12)]
63
+ for (let offset = 16; offset + 4 <= boxEnd; offset += 4) brands.push(bytes.toString('ascii', offset, offset + 4))
64
+ isoImage = brands.some((brand) => [
65
+ 'avif', 'avis',
66
+ 'heic', 'heix', 'hevc', 'hevx', 'heim', 'heis',
67
+ 'mif1', 'msf1',
68
+ ].includes(brand))
69
+ }
70
+ return jpeg || png || webp || isoImage
71
+ }
72
+
73
+ function outputId(bytes) {
74
+ // Provenance is presentation metadata, not identity. The first publication
75
+ // of identical bytes wins regardless of how that image was later reused.
76
+ const digest = createHash('sha256').update(bytes).digest('hex')
77
+ return `o_${digest.slice(0, 32)}`
78
+ }
79
+
80
+ function validateItemsDir(runDir, expected) {
81
+ const itemsDir = join(runDir, 'items')
82
+ let fd = -1
83
+ try {
84
+ const before = lstatSync(itemsDir)
85
+ if (!before.isDirectory() || before.isSymbolicLink() || (before.mode & 0o777) !== 0o700) {
86
+ fail('Publisher items directory is unavailable.')
87
+ }
88
+ if (typeof process.getuid === 'function' && before.uid !== process.getuid()) {
89
+ fail('Publisher items directory is unavailable.')
90
+ }
91
+ const real = realpathSync(itemsDir)
92
+ if (real !== itemsDir || !real.startsWith(`${runDir}${sep}`)) {
93
+ fail('Publisher items directory is unavailable.')
94
+ }
95
+ fd = openSync(itemsDir, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0))
96
+ const opened = fstatSync(fd)
97
+ const after = lstatSync(itemsDir)
98
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino ||
99
+ after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino ||
100
+ (opened.mode & 0o777) !== 0o700 ||
101
+ (typeof process.getuid === 'function' && opened.uid !== process.getuid())) {
102
+ fail('Publisher items directory is unavailable.')
103
+ }
104
+ if (expected && (opened.dev !== expected.dev || opened.ino !== expected.ino)) {
105
+ fail('Publisher items directory is unavailable.')
106
+ }
107
+ return { path: itemsDir, dev: opened.dev, ino: opened.ino }
108
+ } catch (err) {
109
+ if (err instanceof Error && err.message === 'Publisher items directory is unavailable.') throw err
110
+ fail('Publisher items directory is unavailable.')
111
+ } finally {
112
+ if (fd >= 0) {
113
+ try { closeSync(fd) } catch { /* best effort */ }
114
+ }
115
+ }
116
+ }
117
+
118
+ function readManifestIds(path) {
119
+ if (!existsSync(path)) return new Set()
120
+ let contents = ''
121
+ let fd = -1
122
+ try {
123
+ const stat = lstatSync(path)
124
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MANIFEST_MAX_BYTES) {
125
+ fail('Publisher manifest is unavailable.')
126
+ }
127
+ fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
128
+ contents = readFileSync(fd, 'utf8')
129
+ } catch {
130
+ fail('Publisher manifest is unavailable.')
131
+ } finally {
132
+ if (fd >= 0) {
133
+ try { closeSync(fd) } catch { /* best effort */ }
134
+ }
135
+ }
136
+ const ids = new Set()
137
+ for (const line of contents.split('\n')) {
138
+ if (!line || line.length > 512) continue
139
+ try {
140
+ const item = JSON.parse(line)
141
+ if (item?.v === 1 && item?.type === 'publish' && OUTPUT_ID_RE.test(item.id) && PROVENANCE.has(item.provenance)) {
142
+ ids.add(item.id)
143
+ }
144
+ } catch {
145
+ // A process can die between append bytes. A malformed tail never makes
146
+ // a valid earlier publication disappear.
147
+ }
148
+ }
149
+ return ids
150
+ }
151
+
152
+ function acquireLock(runDir) {
153
+ const lockDir = join(runDir, '.publish.lock')
154
+ const deadline = Date.now() + 5_000
155
+ while (Date.now() < deadline) {
156
+ try {
157
+ mkdirSync(lockDir, { mode: 0o700 })
158
+ return lockDir
159
+ } catch (err) {
160
+ if (err?.code !== 'EEXIST') fail('Publisher lock is unavailable.')
161
+ try {
162
+ if (Date.now() - statSync(lockDir).mtimeMs > 30_000) {
163
+ rmSync(lockDir, { recursive: true, force: true })
164
+ continue
165
+ }
166
+ } catch {
167
+ // The owner may have released it between the failed mkdir and stat.
168
+ }
169
+ sleep(25)
170
+ }
171
+ }
172
+ fail('Publisher is busy; try once more.')
173
+ }
174
+
175
+ function readCapability(runDir, supplied) {
176
+ if (!supplied || Buffer.byteLength(supplied) > 256) fail('Publisher capability is unavailable.')
177
+ let expected = ''
178
+ try {
179
+ expected = readFileSync(join(runDir, '.capability'), 'utf8').trim()
180
+ } catch {
181
+ fail('Publisher capability is unavailable.')
182
+ }
183
+ const a = Buffer.from(supplied)
184
+ const b = Buffer.from(expected)
185
+ if (a.length !== b.length || !timingSafeEqual(a, b)) fail('Publisher capability is unavailable.')
186
+ }
187
+
188
+ function validateRunDir(rawDir) {
189
+ if (!rawDir || !isAbsolute(rawDir)) fail('Publisher directory is unavailable.')
190
+ let runDir
191
+ let tmpRoot
192
+ try {
193
+ runDir = realpathSync(rawDir)
194
+ tmpRoot = realpathSync('/tmp')
195
+ const stat = lstatSync(runDir)
196
+ if (!stat.isDirectory() || stat.isSymbolicLink()) fail('Publisher directory is unavailable.')
197
+ if ((stat.mode & 0o777) !== 0o700) fail('Publisher directory is not private.')
198
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) fail('Publisher directory is unavailable.')
199
+ } catch {
200
+ fail('Publisher directory is unavailable.')
201
+ }
202
+ if (!runDir.startsWith(`${tmpRoot}${sep}`) || !basename(runDir).startsWith(RUN_DIR_PREFIX)) {
203
+ fail('Publisher directory is unavailable.')
204
+ }
205
+ return runDir
206
+ }
207
+
208
+ function readLocalImage(source) {
209
+ if (!source || !isAbsolute(source) || /^[a-z][a-z0-9+.-]*:/i.test(source) || source.startsWith('//')) {
210
+ fail('Publisher accepts an absolute local file, never a URL.')
211
+ }
212
+ let fd = -1
213
+ try {
214
+ const before = lstatSync(source)
215
+ if (!before.isFile() || before.isSymbolicLink() || before.size <= 0 || before.size > MAX_IMAGE_BYTES) {
216
+ fail('Source must be a supported local image no larger than 16 MiB.')
217
+ }
218
+ fd = openSync(source, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
219
+ const opened = fstatSync(fd)
220
+ if (!opened.isFile() || opened.size !== before.size || opened.ino !== before.ino || opened.dev !== before.dev) {
221
+ fail('Source image changed while publishing.')
222
+ }
223
+ const bytes = readFileSync(fd)
224
+ const after = fstatSync(fd)
225
+ if (after.size !== opened.size || after.ino !== opened.ino || after.dev !== opened.dev) {
226
+ fail('Source image changed while publishing.')
227
+ }
228
+ if (!isSupportedMagic(bytes)) fail('Only JPEG, PNG, WebP, HEIC, HEIF, or AVIF images can be published.')
229
+ return bytes
230
+ } catch (err) {
231
+ if (typeof err?.message === 'string' && err.message.startsWith('Source ')) throw err
232
+ fail('Source image is unavailable.')
233
+ } finally {
234
+ if (fd >= 0) {
235
+ try { closeSync(fd) } catch { /* best effort */ }
236
+ }
237
+ }
238
+ }
239
+
240
+ function main() {
241
+ const [provenance, source, ...extra] = process.argv.slice(2)
242
+ if (!PROVENANCE.has(provenance) || !source || extra.length > 0) {
243
+ fail('Usage: publisher <generated|research|email> <absolute-local-image>')
244
+ }
245
+
246
+ const runDir = validateRunDir(process.env.COS_OUTPUT_IMAGE_DIR)
247
+ readCapability(runDir, process.env.COS_OUTPUT_IMAGE_TOKEN)
248
+ const bytes = readLocalImage(source)
249
+ const id = outputId(bytes)
250
+ const maxImages = configuredMaxImages()
251
+ const manifestPath = join(runDir, 'manifest.jsonl')
252
+ const items = validateItemsDir(runDir)
253
+ const itemsDir = items.path
254
+ const lockDir = acquireLock(runDir)
255
+
256
+ try {
257
+ const published = readManifestIds(manifestPath)
258
+ if (!published.has(id) && published.size >= maxImages) {
259
+ fail(maxImages === 0 ? 'This response cannot accept output images.' : `This response already has ${maxImages} published images.`)
260
+ }
261
+
262
+ const target = join(itemsDir, `${id}.img`)
263
+ if (!existsSync(target)) {
264
+ const tmp = join(itemsDir, `.${id}-${process.pid}.tmp`)
265
+ let fd = -1
266
+ try {
267
+ validateItemsDir(runDir, items)
268
+ fd = openSync(
269
+ tmp,
270
+ constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0),
271
+ 0o600,
272
+ )
273
+ const opened = fstatSync(fd)
274
+ if (!opened.isFile() || opened.nlink !== 1 || (opened.mode & 0o777) !== 0o600) {
275
+ fail('Publisher items directory is unavailable.')
276
+ }
277
+ // If the path was swapped between validation and open, detect that
278
+ // before writing any source bytes to the selected directory.
279
+ validateItemsDir(runDir, items)
280
+ writeSync(fd, bytes)
281
+ fsyncSync(fd)
282
+ closeSync(fd)
283
+ fd = -1
284
+ validateItemsDir(runDir, items)
285
+ renameSync(tmp, target)
286
+ validateItemsDir(runDir, items)
287
+ } finally {
288
+ if (fd >= 0) {
289
+ try { closeSync(fd) } catch { /* best effort */ }
290
+ }
291
+ try { rmSync(tmp, { force: true }) } catch { /* best effort */ }
292
+ }
293
+ }
294
+
295
+ if (!published.has(id)) {
296
+ const line = `${JSON.stringify({ v: 1, type: 'publish', id, provenance })}\n`
297
+ const fd = openSync(
298
+ manifestPath,
299
+ constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0),
300
+ 0o600,
301
+ )
302
+ try {
303
+ const stat = fstatSync(fd)
304
+ if (!stat.isFile()) fail('Publisher manifest is unavailable.')
305
+ writeSync(fd, line)
306
+ fsyncSync(fd)
307
+ } finally {
308
+ closeSync(fd)
309
+ }
310
+ chmodSync(manifestPath, 0o600)
311
+ }
312
+ process.stdout.write(`Published ${provenance} image.\n`)
313
+ } finally {
314
+ rmSync(lockDir, { recursive: true, force: true })
315
+ }
316
+ }
317
+
318
+ try {
319
+ main()
320
+ } catch (err) {
321
+ const message = err instanceof Error ? err.message : 'Image publication failed.'
322
+ process.stderr.write(`${message}\n`)
323
+ process.exitCode = 1
324
+ }
@@ -0,0 +1,16 @@
1
+ // Must remain the first import in server/index.ts. Claim the same machine-wide
2
+ // slot used by the installed LaunchAgent before mutable routes or stores load.
3
+ import './env.js'
4
+ import { acquireServerInstanceLock, ServerInstanceActiveError } from './lib/server-instance-lock.js'
5
+
6
+ try {
7
+ const instanceLock = acquireServerInstanceLock()
8
+ process.once('exit', instanceLock.release)
9
+ } catch (error) {
10
+ if (error instanceof ServerInstanceActiveError) {
11
+ console.error(`[COS API] Startup refused: ${error.message}`)
12
+ process.exit(75)
13
+ }
14
+ console.error('[COS API] Startup refused: single-instance lock failed.', error)
15
+ process.exit(74)
16
+ }
package/server/index.ts CHANGED
@@ -1,12 +1,12 @@
1
- // Load .env FIRST ESM evaluates this module before subsequent imports,
2
- // ensuring process.env is populated before python-bridge.ts reads COS_SCRIPTS_DIR.
3
- import './env.js'
1
+ // Load env and claim the one server slot before any mutable module initializes.
2
+ import './bootstrap.js'
4
3
 
5
4
  import express from 'express'
6
5
  import cors from 'cors'
7
6
  import path from 'node:path'
8
7
  import { fileURLToPath } from 'node:url'
9
8
  import { createServer as createHttpsServer } from 'node:https'
9
+ import { createServer as createHttpServer } from 'node:http'
10
10
  import { readFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs'
11
11
  import { networkInterfaces, homedir } from 'node:os'
12
12
  import { join } from 'node:path'
@@ -23,13 +23,23 @@ import { openaiKeyRouter } from './routes/openai-key.js'
23
23
  import { messageRefRouter } from './routes/message-ref.js'
24
24
  import { archiveRouter } from './routes/archive.js'
25
25
  import { sessionsRouter } from './routes/sessions.js'
26
+ import { mediaRouter, mediaBodyParser } from './routes/media.js'
26
27
  import { prewarmContext } from './lib/context-builder.js'
27
28
  import { preWarmCLI } from './lib/claude-bridge.js'
29
+ import { getCodexRunConfig } from './lib/codex-run-ledger.js'
30
+ import {
31
+ startCodexModelCatalogRefresh,
32
+ stopCodexModelCatalogRefresh,
33
+ } from './lib/codex-model-catalog.js'
28
34
  import { startWhisperServer, stopWhisperServer } from './lib/whisper-local.js'
29
35
  import { initSileroVAD } from './lib/vad-silero.js'
30
36
  import { initSessionCache } from './lib/session-cache-writer.js'
31
37
  import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
32
38
  import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
39
+ import { getMediaStore } from './lib/media-store.js'
40
+ import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
41
+ import { serverMetrics } from './lib/server-metrics.js'
42
+ import { initializeServerInstanceId } from './lib/server-instance-id.js'
33
43
 
34
44
  const app = express()
35
45
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -61,12 +71,6 @@ if (API_TOKEN_AUTO) {
61
71
  } catch { /* read-only home — token stays per-session */ }
62
72
  }
63
73
 
64
- // Server metrics — shared with /api/health for monitoring
65
- export const serverMetrics = {
66
- startedAt: Date.now(),
67
- requestCount: 0,
68
- }
69
-
70
74
  // IP allowlist — only accept connections from localhost, meshnet, and private networks.
71
75
  // Blocks untrusted public access (coffee-shop WiFi, the open internet) while keeping all
72
76
  // local + meshnet (Tailscale/CGNAT) + LAN consumers working.
@@ -99,8 +103,6 @@ app.use(cors({
99
103
  cb(new Error('CORS blocked'))
100
104
  },
101
105
  }))
102
- app.use(express.json({ limit: '10mb' }))
103
-
104
106
  // Auth middleware — always active (token is auto-generated if not set)
105
107
  app.use('/api', (req, res, next) => {
106
108
  // Allow health checks, display stream, and client diagnostics without auth.
@@ -120,6 +122,11 @@ app.use('/api', (req, res, next) => {
120
122
  next()
121
123
  })
122
124
 
125
+ // Authenticate before parsing large upload bodies. The 16 MB allowance stays
126
+ // scoped to /api/media; every other route retains the 10 MB ceiling.
127
+ app.use('/api/media', mediaBodyParser)
128
+ app.use(express.json({ limit: '10mb' }))
129
+
123
130
  // Request counter — must be before route registrations
124
131
  app.use((_req, _res, next) => {
125
132
  serverMetrics.requestCount++
@@ -139,6 +146,7 @@ app.use('/api', openaiKeyRouter)
139
146
  app.use('/api', messageRefRouter)
140
147
  app.use('/api', archiveRouter)
141
148
  app.use('/api', sessionsRouter)
149
+ app.use('/api', mediaRouter)
142
150
 
143
151
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
144
152
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -164,12 +172,19 @@ process.on('SIGTERM', () => {
164
172
  // Production stops (kill, service managers) send SIGTERM — flush session logs
165
173
  // exactly like SIGINT so active conversations aren't lost on shutdown.
166
174
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
175
+ stopCodexModelCatalogRefresh()
176
+ stopWhisperServer()
177
+ process.exit(0)
178
+ })
179
+ process.on('SIGINT', () => {
180
+ logActiveSessionsOnShutdown()
181
+ stopCodexModelCatalogRefresh()
167
182
  stopWhisperServer()
168
183
  process.exit(0)
169
184
  })
170
- process.on('SIGINT', () => { logActiveSessionsOnShutdown(); stopWhisperServer(); process.exit(0) })
171
185
 
172
- // Crash protection log and survive instead of dying mid-meeting
186
+ // Crash protection for runtime work. Listener failures are handled separately
187
+ // and exit immediately so a supervisor can restart a clean, unified process.
173
188
  process.on('uncaughtException', (err) => {
174
189
  console.error('[CRASH GUARD] Uncaught exception (server stays alive):', err.message)
175
190
  console.error(err.stack)
@@ -184,20 +199,27 @@ process.on('unhandledRejection', (reason: any) => {
184
199
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
185
200
  const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
186
201
  const certDir = path.join(__dirname, 'certs')
202
+ const listeners: RequiredListener[] = []
187
203
  if (existsSync(path.join(certDir, 'cert.pem'))) {
188
204
  const httpsServer = createHttpsServer({
189
205
  cert: readFileSync(path.join(certDir, 'cert.pem')),
190
206
  key: readFileSync(path.join(certDir, 'key.pem')),
191
207
  }, app)
192
- httpsServer.listen(HTTPS_PORT, BIND_HOST, () => {
193
- console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
194
- })
208
+ listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
195
209
  } else {
196
210
  console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
197
211
  }
198
212
 
199
- app.listen(PORT, BIND_HOST, () => {
213
+ const httpServer = createHttpServer(app)
214
+ listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
215
+
216
+ listenRequiredServers(listeners).then(() => {
217
+ const serverInstanceId = initializeServerInstanceId()
218
+ if (listeners.some(listener => listener.label === 'HTTPS')) {
219
+ console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
220
+ }
200
221
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
222
+ console.log(`[COS API] Server instance: ${serverInstanceId}`)
201
223
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
202
224
 
203
225
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
@@ -228,15 +250,25 @@ app.listen(PORT, BIND_HOST, () => {
228
250
  console.log('')
229
251
  }
230
252
 
231
- // Check Claude CLI availability the chat backend
253
+ // Check Claude CLI availability. Codex-only installs remain fully valid.
254
+ let claudeAvailable = false
232
255
  try {
233
256
  execSync('claude --version', { timeout: 5000, stdio: 'pipe' })
257
+ claudeAvailable = true
234
258
  console.log('[COS API] Claude Code CLI detected')
235
259
  } catch {
236
260
  console.warn('[COS API] Claude Code CLI not found — install from https://claude.ai/download')
237
- console.warn('[COS API] AI queries will not work without the Claude Code CLI')
261
+ console.warn('[COS API] Claude models unavailable; Codex models still work when Codex CLI is installed')
238
262
  }
239
263
 
264
+ const codexConfig = getCodexRunConfig()
265
+ console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
266
+ console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
267
+ console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
268
+ // Refresh immediately and then periodically. The catalog module retains the
269
+ // last-known-good snapshot if Codex is temporarily unavailable.
270
+ startCodexModelCatalogRefresh()
271
+
240
272
  if (COS_MODE) {
241
273
  initSessionCache()
242
274
  // Pre-warm context cache so first query doesn't wait for the pipeline
@@ -251,9 +283,17 @@ app.listen(PORT, BIND_HOST, () => {
251
283
  const vadOk = initSileroVAD()
252
284
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
253
285
 
254
- // Pre-warm the Claude CLI so the first query doesn't eat a 2-15s cold start
255
- preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
286
+ // Pre-warm Claude only when installed so Codex-only startup stays quiet.
287
+ if (claudeAvailable) {
288
+ preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
289
+ }
256
290
 
257
291
  // Auto-snapshot active sessions every 5 min (survives restarts)
258
292
  startAutoSnapshot(5 * 60_000)
293
+
294
+ // Durable media GC (staged/reserved expiry + generated-image content TTL).
295
+ getMediaStore().startGC()
296
+ }).catch((error: NodeJS.ErrnoException) => {
297
+ console.error(`[COS API] Fatal listener startup: ${error.message}`)
298
+ process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
259
299
  })