@gotcos/glasses-server 6.12.6 → 6.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.7
4
+
5
+ Pairs the public server with COS Glasses build 227+ meeting follow-ups while
6
+ preserving every existing meeting response field and legacy client contract.
7
+
8
+ - **Meeting follow-ups receive canonical source context.** Authenticated
9
+ meeting-detail responses now add `sourceContent` and `sourceTruncated`, so
10
+ the glasses can attach the actual meeting record before a follow-up query.
11
+ - **Bounded and UTF-8 safe.** Source context is capped at 100 KB without
12
+ splitting a multibyte character. Existing summary and transcript fields are
13
+ unchanged.
14
+ - **Backward compatible.** Older clients ignore the additive fields; newer
15
+ clients no longer display the server-update warning on public installs.
16
+
3
17
  ## 6.12.6
4
18
 
5
19
  Pairs with COS Glasses build 222 to harden local-first meeting recovery and
@@ -486,3 +500,10 @@ it directly, with no second repository to clone.
486
500
  can reach the server over your mesh/LAN. The IP allowlist blocks public traffic.
487
501
  - **Persistent config** at `~/.cos-glasses/.env`.
488
502
  - Requires Node.js 20.11+.
503
+ # 6.13.0
504
+
505
+ - Added a non-interactive managed-server entrypoint for the COS Control macOS app.
506
+ - Added authenticated maintenance status and guarded local Whisper restart contracts.
507
+ - Added `--prepare-only` to the existing guided launcher so first-run dependencies can be prepared without leaving a second server process running.
508
+ - Added a provider-neutral managed work-folder setting while preserving the existing interactive launch-directory behavior.
509
+ - Kept the existing `npx @gotcos/glasses-server` foreground workflow fully compatible.
package/README.md CHANGED
@@ -11,6 +11,16 @@ API key is pasted into the phone for chat.
11
11
  npx --yes @gotcos/glasses-server@latest
12
12
  ```
13
13
 
14
+ For the optional COS Control macOS menu bar app, prepare dependencies without
15
+ leaving a foreground server running:
16
+
17
+ ```bash
18
+ npx --yes @gotcos/glasses-server@latest --prepare-only
19
+ ```
20
+
21
+ COS Control then installs the same npm package as a launchd-managed runtime.
22
+ The original foreground command remains supported and unchanged.
23
+
14
24
  The launcher checks Node, finds your CLI, checks voice and image processing,
15
25
  downloads the local voice model when needed, writes `~/.cos-glasses/.env`, and
16
26
  starts the server on `0.0.0.0:3141`. On boot it prints
package/bin/cli.cjs CHANGED
@@ -41,6 +41,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
41
41
  console.log('')
42
42
  console.log(' Usage:')
43
43
  console.log(' npx --yes @gotcos/glasses-server@latest')
44
+ console.log(' npx --yes @gotcos/glasses-server@latest --prepare-only')
44
45
  console.log('')
45
46
  console.log(' Requirements:')
46
47
  console.log(' - Node.js 20.11+')
@@ -174,6 +175,17 @@ try {
174
175
  process.exit(1)
175
176
  }
176
177
 
178
+ // Controller probes are deliberately read-only. They verify Node, agent auth,
179
+ // and the packaged runtime without creating config, downloading models,
180
+ // changing permissions, or starting a listener.
181
+ if (process.argv.includes('--prepare-only')) {
182
+ console.log('')
183
+ console.log(green(' ✓ Non-mutating readiness check complete'))
184
+ console.log(' COS Control can perform guided installation without hidden setup side effects.')
185
+ console.log('')
186
+ process.exit(0)
187
+ }
188
+
177
189
  // Step 4: persistent config at ~/.cos-glasses/ (survives npx cache churn)
178
190
  function securePrivateDirectory(dir) {
179
191
  mkdirSync(dir, { recursive: true, mode: 0o700 })
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Non-interactive entrypoint for trusted local service managers such as
4
+ // COS Control. The existing glasses-server CLI remains the interactive setup
5
+ // path; this launcher assumes ~/.cos-glasses/.env is already configured.
6
+
7
+ const { resolve } = require('node:path')
8
+ const packageJson = require('../package.json')
9
+
10
+ const PKG_ROOT = resolve(__dirname, '..')
11
+
12
+ try {
13
+ require('tsx/cjs')
14
+ } catch {
15
+ console.error('[cos-managed] Package dependencies are incomplete; reinstall @gotcos/glasses-server.')
16
+ process.exit(1)
17
+ }
18
+
19
+ const workDir = process.env.COS_WORKDIR?.trim()
20
+ process.env.COS_MANAGED = '1'
21
+ process.env.COS_ENTRYPOINT = 'managed-server'
22
+ process.env.COS_SERVER_VERSION = packageJson.version
23
+ if (workDir) process.env.COS_LAUNCH_DIR = workDir
24
+
25
+ // The launchd-owned PID is the listener owner. Keeping the listener in this
26
+ // process removes the supervisor/child ambiguity that made lifecycle proof and
27
+ // crash receipts unreliable.
28
+ require(resolve(PKG_ROOT, 'server/index.ts'))
@@ -0,0 +1,23 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "contractVersion": 2,
4
+ "entrypoint": "bin/managed-server.cjs",
5
+ "listenerOwnership": "entrypoint-process",
6
+ "requiredEnvironment": [
7
+ "COS_API_TOKEN",
8
+ "COS_SERVER_GENERATION_ID",
9
+ "COS_MAINTENANCE_GATE_PATH"
10
+ ],
11
+ "optionalEnvironment": [
12
+ "COS_WORKDIR",
13
+ "PORT",
14
+ "HTTPS_PORT",
15
+ "BIND_HOST",
16
+ "COS_DURABLE_QUERY_JOBS"
17
+ ],
18
+ "maintenance": {
19
+ "scope": "cross_boot",
20
+ "adoptionRequired": true,
21
+ "networkRestartEndpoint": false
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.6",
3
+ "version": "6.13.0",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
7
- "glasses-server": "bin/cli.cjs"
7
+ "glasses-server": "bin/cli.cjs",
8
+ "glasses-server-managed": "bin/managed-server.cjs"
8
9
  },
9
10
  "scripts": {
10
11
  "start": "node bin/cli.cjs",
@@ -24,6 +25,8 @@
24
25
  ],
25
26
  "files": [
26
27
  "bin/cli.cjs",
28
+ "bin/managed-server.cjs",
29
+ "managed-runtime-contract.json",
27
30
  "server",
28
31
  "shared",
29
32
  "!server/**/*.test.ts",
package/server/index.ts CHANGED
@@ -28,6 +28,7 @@ import { sessionsRouter } from './routes/sessions.js'
28
28
  import { mediaRouter, mediaBodyParser } from './routes/media.js'
29
29
  import { promptDraftsRouter } from './routes/prompt-drafts.js'
30
30
  import { cliDebugRouter } from './routes/cli-debug.js'
31
+ import { maintenanceRouter } from './routes/maintenance.js'
31
32
  import { prewarmContext } from './lib/context-builder.js'
32
33
  import { preWarmCLI } from './lib/claude-bridge.js'
33
34
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -58,6 +59,13 @@ import {
58
59
  isTailscaleIpv4,
59
60
  } from './lib/network-policy.js'
60
61
  import { timingSafeTokenEqual } from './lib/token-auth.js'
62
+ import { isManagedRuntime } from './lib/managed-runtime.js'
63
+ import {
64
+ acquireMaintenanceWork,
65
+ MaintenanceLifecycleError,
66
+ maintenanceAdmissionsOpen,
67
+ maintenanceErrorPayload,
68
+ } from './lib/maintenance-lifecycle.js'
61
69
 
62
70
  const app = express()
63
71
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -74,6 +82,9 @@ const BIND_HOST = process.env.BIND_HOST ?? '0.0.0.0'
74
82
 
75
83
  // API token — auto-generate if not set so every session is authenticated.
76
84
  const API_TOKEN_AUTO = !process.env.COS_API_TOKEN
85
+ if (isManagedRuntime() && API_TOKEN_AUTO) {
86
+ throw new Error('Managed COS startup requires a pre-provisioned COS_API_TOKEN.')
87
+ }
77
88
  const API_TOKEN = process.env.COS_API_TOKEN ?? `_${randomBytes(32).toString('base64url')}`
78
89
  process.env.COS_API_TOKEN = API_TOKEN // make available to routes that check it
79
90
  // Persist an auto-generated token to ~/.cos-glasses/.env so it SURVIVES restarts.
@@ -135,6 +146,42 @@ app.use('/api', (req, res, next) => {
135
146
  next()
136
147
  })
137
148
 
149
+ // Fail-closed catch-all for mutation routes that do not own a more specific
150
+ // lifecycle lease below. This closes the admission/drain race for secondary
151
+ // state-changing APIs (media, sessions, settings, diagnostics) without
152
+ // double-owning provider and recording continuations whose routes retain work
153
+ // through their true terminal boundary.
154
+ app.use('/api', (req, res, next) => {
155
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
156
+ const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
157
+ || req.path === '/query'
158
+ || req.path === '/transcribe'
159
+ || req.path.startsWith('/transcribe-stream')
160
+ || req.path === '/meeting/save'
161
+ || req.path.startsWith('/prompt-drafts')
162
+ || req.path.startsWith('/maintenance/drain')
163
+ if (lifecycleOwned) return next()
164
+
165
+ try {
166
+ const lease = acquireMaintenanceWork('api_mutation')
167
+ let released = false
168
+ const release = () => {
169
+ if (released) return
170
+ released = true
171
+ lease.release()
172
+ }
173
+ res.once('finish', release)
174
+ res.once('close', release)
175
+ next()
176
+ } catch (error) {
177
+ if (error instanceof MaintenanceLifecycleError) {
178
+ if (error.retryAfterSeconds != null) res.setHeader('Retry-After', String(error.retryAfterSeconds))
179
+ return res.status(error.status).json(maintenanceErrorPayload(error))
180
+ }
181
+ return res.status(500).json({ error: 'maintenance_internal_error', retryable: false })
182
+ }
183
+ })
184
+
138
185
  // Authenticate before parsing large upload bodies. The 16 MB allowance stays
139
186
  // scoped to /api/media; every other route retains the 10 MB ceiling.
140
187
  app.use('/api/media', mediaBodyParser)
@@ -167,6 +214,7 @@ app.use('/api', sessionsRouter)
167
214
  app.use('/api', mediaRouter)
168
215
  app.use('/api', promptDraftsRouter)
169
216
  app.use('/api', cliDebugRouter)
217
+ app.use('/api', maintenanceRouter)
170
218
 
171
219
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
172
220
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -242,6 +290,7 @@ listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP'
242
290
 
243
291
  listenRequiredServers(listeners).then(() => {
244
292
  const serverInstanceId = initializeServerInstanceId()
293
+ const startupAdmissionsOpen = maintenanceAdmissionsOpen()
245
294
  if (listeners.some(listener => listener.label === 'HTTPS')) {
246
295
  console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
247
296
  }
@@ -249,17 +298,21 @@ listenRequiredServers(listeners).then(() => {
249
298
  console.log(`[COS API] Server instance: ${serverInstanceId}`)
250
299
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
251
300
 
252
- void initQueryJobRuntime().then(health => {
253
- if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
254
- console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
255
- } else {
256
- console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
257
- }
258
- }).catch(error => {
259
- // The store remains degraded and rejects admission. Legacy /api/query is
260
- // still mounted, so disabling the feature flag is an immediate rollback.
261
- console.error('[COS API] Durable query-job store unavailable:', error)
262
- })
301
+ if (startupAdmissionsOpen) {
302
+ void initQueryJobRuntime().then(health => {
303
+ if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
304
+ console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
305
+ } else {
306
+ console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
307
+ }
308
+ }).catch(error => {
309
+ // The store remains degraded and rejects admission. Legacy /api/query is
310
+ // still mounted, so disabling the feature flag is an immediate rollback.
311
+ console.error('[COS API] Durable query-job store unavailable:', error)
312
+ })
313
+ } else {
314
+ console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller adoption')
315
+ }
263
316
 
264
317
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
265
318
  // not paste-able — enumerate real interfaces and label the Tailscale one.
@@ -281,8 +334,10 @@ listenRequiredServers(listeners).then(() => {
281
334
  }
282
335
  } catch { /* interface enumeration is best-effort */ }
283
336
 
284
- // Print the full API token when auto-generated the user pastes it into the app.
285
- if (API_TOKEN_AUTO) {
337
+ // Interactive standalone startup may print a newly generated pairing token.
338
+ // Managed startup never generates or logs credentials; COS Control copies the
339
+ // pre-provisioned token through the local pasteboard flow instead.
340
+ if (API_TOKEN_AUTO && !isManagedRuntime()) {
286
341
  console.log('')
287
342
  console.log(`[COS API] API Token: ${API_TOKEN}`)
288
343
  console.log('[COS API] ^ paste this into the COS Glasses app' + (API_TOKEN_PERSISTED ? ' — saved to ~/.cos-glasses/.env so it stays the same across restarts' : ' (set COS_API_TOKEN in .env for a fixed token)'))
@@ -306,32 +361,34 @@ listenRequiredServers(listeners).then(() => {
306
361
  console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
307
362
  // Refresh immediately and then periodically. The catalog module retains the
308
363
  // last-known-good snapshot if Codex is temporarily unavailable.
309
- startCodexModelCatalogRefresh()
364
+ if (startupAdmissionsOpen) {
365
+ startCodexModelCatalogRefresh()
310
366
 
311
- if (COS_MODE) {
312
- initSessionCache()
313
- // Pre-warm context cache so first query doesn't wait for the pipeline
314
- prewarmContext()
315
- }
316
- // Start local whisper-server (model stays in RAM for ~50ms transcription)
317
- startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
318
- // Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
319
- const embeddingOk = initSpeakerEmbeddings()
320
- console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
321
- // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
322
- const vadOk = initSileroVAD()
323
- console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
367
+ if (COS_MODE) {
368
+ initSessionCache()
369
+ // Pre-warm context cache so first query doesn't wait for the pipeline
370
+ prewarmContext()
371
+ }
372
+ // Start local whisper-server (model stays in RAM for ~50ms transcription)
373
+ startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
374
+ // Initialize speaker embeddings (voiceprint-based diarization) — fails soft if model absent
375
+ const embeddingOk = initSpeakerEmbeddings()
376
+ console.log(`[startup] Speaker embeddings: ${embeddingOk ? 'active' : 'disabled (model not found)'}`)
377
+ // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
378
+ const vadOk = initSileroVAD()
379
+ console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
324
380
 
325
- // Pre-warm Claude only when installed so Codex-only startup stays quiet.
326
- if (claudeAvailable) {
327
- preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
328
- }
381
+ // Pre-warm Claude only when installed so Codex-only startup stays quiet.
382
+ if (claudeAvailable) {
383
+ preWarmCLI().catch(err => console.error('[startup] CLI pre-warm error:', err))
384
+ }
329
385
 
330
- // Auto-snapshot active sessions every 5 min (survives restarts)
331
- startAutoSnapshot(5 * 60_000)
386
+ // Auto-snapshot active sessions every 5 min (survives restarts)
387
+ startAutoSnapshot(5 * 60_000)
332
388
 
333
- // Durable media GC (staged/reserved expiry + generated-image content TTL).
334
- getMediaStore().startGC()
389
+ // Durable media GC (staged/reserved expiry + generated-image content TTL).
390
+ getMediaStore().startGC()
391
+ }
335
392
  }).catch((error: NodeJS.ErrnoException) => {
336
393
  console.error(`[COS API] Fatal listener startup: ${error.message}`)
337
394
  process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
@@ -14,16 +14,22 @@ import { join, resolve } from 'node:path'
14
14
 
15
15
  let cached: string | null | undefined
16
16
 
17
- /** The user's launch directory IF it contains a COS brain; otherwise null. */
18
- export function cosBrainDir(): string | null {
19
- if (cached !== undefined) return cached
20
- const raw = process.env.COS_LAUNCH_DIR?.trim()
21
- if (!raw) { cached = null; return cached }
22
- const dir = resolve(raw)
17
+ export function resolveCosBrainDir(raw: string | undefined): string | null {
18
+ const candidate = raw?.trim()
19
+ if (!candidate) return null
20
+ const dir = resolve(candidate)
23
21
  const hasBrain =
24
22
  existsSync(join(dir, '.cos', 'manifest.json')) ||
25
23
  existsSync(join(dir, 'AGENTS.md')) ||
26
24
  existsSync(join(dir, 'CLAUDE.md'))
27
- cached = hasBrain ? dir : null
25
+ return hasBrain ? dir : null
26
+ }
27
+
28
+ /** The user's launch directory IF it contains a COS brain; otherwise null. */
29
+ export function cosBrainDir(): string | null {
30
+ if (cached !== undefined) return cached
31
+ // COS_WORKDIR is the provider-neutral managed setting. COS_LAUNCH_DIR stays
32
+ // as the interactive npx compatibility path.
33
+ cached = resolveCosBrainDir(process.env.COS_WORKDIR ?? process.env.COS_LAUNCH_DIR)
28
34
  return cached
29
35
  }