@gotcos/glasses-server 6.5.0 → 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.
package/.env.example CHANGED
@@ -23,6 +23,11 @@ BIND_HOST=0.0.0.0
23
23
  # ~/.cos-glasses/data/media alongside the standalone conversation/archive data.
24
24
  # COS_MEDIA_ROOT=/path/on-a-local-volume/media
25
25
 
26
+ # Optional logical server identity location. Most installs should keep the
27
+ # default (~/.cos-glasses/server-instance-id) so reconnects can verify the same
28
+ # server after Wi-Fi/Tailscale changes and process restarts.
29
+ # COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
30
+
26
31
  # ── THE LLM (chat) ──────────────────────────────────────────────────────
27
32
  # Chat runs through your LOCAL agent CLI — NOT an API key:
28
33
  # Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.6.0
4
+
5
+ Reconnect compatibility for COS Glasses build 188, without importing private
6
+ COS day-context or Mac service-control behavior into the public package.
7
+
8
+ - **Stable logical server identity.** The server creates one atomic UUID under
9
+ `~/.cos-glasses/server-instance-id`, preserves it across process and network
10
+ restarts, and returns it from authenticated `/api/models` probes. Files are
11
+ mode `0600`; identity is minted only after every required listener binds.
12
+ - **Boot-scoped display cursors.** Display events receive one publish-owned ID
13
+ before fan-out, so multiple subscribers see the same cursor and cannot
14
+ duplicate replay records. Each process boot has a distinct UUID.
15
+ - **Deterministic reconnect handshake.** `/api/display-stream` emits `ready`
16
+ before application events, accepts boot/event cursors, replays the last 200
17
+ publish-owned events, and reports typed `boot_changed`, `cursor_ahead`, or
18
+ `buffer_overflow` gaps so clients reconcile durable history instead of
19
+ guessing or silently dropping replies.
20
+ - **Privacy boundary preserved.** Authenticated query activity remains off the
21
+ unauthenticated global display bus. The npm server does not include private
22
+ daily evidence exports, personal COS paths, launchd ownership, or remote
23
+ machine-restart controls.
24
+ - **Backward compatible.** Older clients can continue opening the same SSE
25
+ endpoint and ignoring the additive `ready`, cursor metadata, and replay-gap
26
+ events.
27
+
3
28
  ## 6.5.0
4
29
 
5
30
  Durable phone photos and assistant-selected output images for COS Glasses
package/README.md CHANGED
@@ -16,7 +16,10 @@ downloads the local voice model when needed, writes `~/.cos-glasses/.env`, and
16
16
  starts the server on `0.0.0.0:3141`. On boot it prints
17
17
  an **API token** — paste that into the COS Glasses app. Only one COS Glasses
18
18
  server may run on a Mac at a time; a second `npx` or source runner exits before
19
- opening ports or touching shared conversation/media state.
19
+ opening ports or touching shared conversation/media state. Version 6.6.0 also
20
+ gives that server a durable identity and boot-scoped display replay, allowing
21
+ build 188+ to reconnect after a Tailscale, Wi-Fi, or process interruption
22
+ without silently losing completed replies.
20
23
 
21
24
  ## Requirements
22
25
 
@@ -87,6 +90,7 @@ BIND_HOST=0.0.0.0 npm run start:server
87
90
  ## Troubleshooting
88
91
 
89
92
  - *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
93
+ - *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
90
94
  - *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
91
95
  - *Voice getting billed?* — install `whisper-cpp` for free local transcription.
92
96
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.5.0",
3
+ "version": "6.6.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": {
package/server/index.ts CHANGED
@@ -38,6 +38,8 @@ import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
38
38
  import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
39
39
  import { getMediaStore } from './lib/media-store.js'
40
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'
41
43
 
42
44
  const app = express()
43
45
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -69,12 +71,6 @@ if (API_TOKEN_AUTO) {
69
71
  } catch { /* read-only home — token stays per-session */ }
70
72
  }
71
73
 
72
- // Server metrics — shared with /api/health for monitoring
73
- export const serverMetrics = {
74
- startedAt: Date.now(),
75
- requestCount: 0,
76
- }
77
-
78
74
  // IP allowlist — only accept connections from localhost, meshnet, and private networks.
79
75
  // Blocks untrusted public access (coffee-shop WiFi, the open internet) while keeping all
80
76
  // local + meshnet (Tailscale/CGNAT) + LAN consumers working.
@@ -218,10 +214,12 @@ const httpServer = createHttpServer(app)
218
214
  listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
219
215
 
220
216
  listenRequiredServers(listeners).then(() => {
217
+ const serverInstanceId = initializeServerInstanceId()
221
218
  if (listeners.some(listener => listener.label === 'HTTPS')) {
222
219
  console.log(`[COS API] HTTPS server running on https://${BIND_HOST}:${HTTPS_PORT}`)
223
220
  }
224
221
  console.log(`[COS API] HTTP server running on http://${BIND_HOST}:${PORT}`)
222
+ console.log(`[COS API] Server instance: ${serverInstanceId}`)
225
223
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
226
224
 
227
225
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
@@ -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
+ }
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import {
3
+ chmodSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ writeFileSync,
8
+ } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { dirname, join } from 'node:path'
11
+
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
13
+
14
+ let initializedId: string | null = null
15
+
16
+ export function defaultServerInstanceIdPath(): string {
17
+ return process.env.COS_SERVER_INSTANCE_ID_PATH
18
+ ?? join(homedir(), '.cos-glasses', 'server-instance-id')
19
+ }
20
+
21
+ /**
22
+ * Initialize only after every required listener binds. A failed or half-bound
23
+ * process must not mint an identity that a client later trusts as healthy.
24
+ */
25
+ export function initializeServerInstanceId(path = defaultServerInstanceIdPath()): string {
26
+ if (initializedId) return initializedId
27
+
28
+ try {
29
+ const existing = readFileSync(path, 'utf8').trim()
30
+ if (UUID_RE.test(existing)) {
31
+ chmodSync(path, 0o600)
32
+ initializedId = existing
33
+ return existing
34
+ }
35
+ } catch {
36
+ // Missing or invalid state is replaced atomically below.
37
+ }
38
+
39
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
40
+ const id = randomUUID()
41
+ const tmp = `${path}.tmp-${process.pid}-${randomUUID()}`
42
+ writeFileSync(tmp, `${id}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' })
43
+ renameSync(tmp, path)
44
+ chmodSync(path, 0o600)
45
+ initializedId = id
46
+ return id
47
+ }
48
+
49
+ export function getServerInstanceId(): string | null {
50
+ return initializedId
51
+ }
52
+
53
+ export function __resetServerInstanceIdForTests(): void {
54
+ initializedId = null
55
+ }
@@ -0,0 +1,7 @@
1
+ import { randomUUID } from 'node:crypto'
2
+
3
+ export const serverMetrics = {
4
+ bootId: randomUUID(),
5
+ startedAt: Date.now(),
6
+ requestCount: 0,
7
+ }
@@ -2,15 +2,28 @@
2
2
  // Any connected glasses client receives real-time query responses
3
3
  // regardless of which interface submitted the query
4
4
 
5
- import { Router } from 'express'
6
- import { onDisplay, emitDisplay } from '../lib/display-bus.js'
5
+ import { Router, type Response } from 'express'
6
+ import {
7
+ emitDisplay,
8
+ getDisplayWatermark,
9
+ onDisplay,
10
+ replayDisplayEvents,
11
+ type PublishedDisplayEvent,
12
+ } from '../lib/display-bus.js'
7
13
 
8
14
  export const displayRouter = Router()
9
15
 
10
- // Replay buffer last N events so reconnecting clients don't miss in-flight data
11
- const REPLAY_BUFFER_SIZE = 20
12
- let eventId = 0
13
- const replayBuffer: Array<{ id: number; type: string; data: string }> = []
16
+ function writeEvent(res: Response, event: PublishedDisplayEvent): void {
17
+ const data = JSON.stringify({
18
+ ...event.data,
19
+ _cosDisplayCursor: {
20
+ bootId: event.bootId,
21
+ eventId: event.eventId,
22
+ publishedAt: event.publishedAt,
23
+ },
24
+ })
25
+ res.write(`id: ${event.bootId}:${event.eventId}\nevent: ${event.type}\ndata: ${data}\n\n`)
26
+ }
14
27
 
15
28
  displayRouter.get('/display-stream', (req, res) => {
16
29
  res.writeHead(200, {
@@ -25,15 +38,29 @@ displayRouter.get('/display-stream', (req, res) => {
25
38
  // Tell EventSource to retry quickly on disconnect (3s instead of browser default ~5-10s)
26
39
  res.write('retry: 3000\n\n')
27
40
 
28
- // Replay missed events if client sends Last-Event-ID (browser does this automatically)
29
- const lastId = parseInt(req.headers['last-event-id'] as string, 10)
30
- if (!isNaN(lastId) && lastId > 0) {
31
- const missed = replayBuffer.filter(e => e.id > lastId)
32
- for (const e of missed) {
33
- res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${e.data}\n\n`)
34
- }
35
- if (missed.length > 0) {
36
- console.log(`[display-bus] Replayed ${missed.length} events for reconnecting client (from id ${lastId})`)
41
+ const headerCursor = String(req.headers['last-event-id'] ?? '')
42
+ const [headerBootId, headerEventId] = headerCursor.includes(':')
43
+ ? headerCursor.split(':', 2)
44
+ : ['', headerCursor]
45
+ const cursorBootId = String(req.query.bootId ?? headerBootId ?? '') || null
46
+ const cursorEventId = Number(req.query.eventId ?? headerEventId ?? 0)
47
+ const replay = replayDisplayEvents(cursorBootId, Number.isFinite(cursorEventId) ? cursorEventId : 0)
48
+
49
+ // Ready is a transport handshake, not proof that replay was consumed. It
50
+ // must precede application events so build 188 can finish admission first.
51
+ const watermark = getDisplayWatermark()
52
+ res.write(`event: ready\ndata: ${JSON.stringify(watermark)}\n\n`)
53
+ if (replay.gap) {
54
+ res.write(`event: replay_gap\ndata: ${JSON.stringify({
55
+ reason: replay.reason,
56
+ requested: { bootId: cursorBootId, eventId: cursorEventId },
57
+ watermark,
58
+ oldestEventId: replay.oldestEventId,
59
+ })}\n\n`)
60
+ } else {
61
+ for (const event of replay.events) writeEvent(res, event)
62
+ if (replay.events.length > 0) {
63
+ console.log(`[display-bus] Replayed ${replay.events.length} publish-owned events after ${cursorEventId}`)
37
64
  }
38
65
  }
39
66
 
@@ -43,13 +70,7 @@ displayRouter.get('/display-stream', (req, res) => {
43
70
  }, 15_000)
44
71
 
45
72
  const unsub = onDisplay((event) => {
46
- eventId++
47
- const data = JSON.stringify(event.data)
48
- // Buffer for replay
49
- replayBuffer.push({ id: eventId, type: event.type, data })
50
- if (replayBuffer.length > REPLAY_BUFFER_SIZE) replayBuffer.shift()
51
- // Send with id for Last-Event-ID tracking
52
- try { res.write(`id: ${eventId}\nevent: ${event.type}\ndata: ${data}\n\n`) } catch { /* client gone */ }
73
+ try { writeEvent(res, event) } catch { /* client gone */ }
53
74
  })
54
75
 
55
76
  req.on('close', () => {
@@ -3,7 +3,8 @@ import { execFile } from 'node:child_process'
3
3
  import { statSync } from 'node:fs'
4
4
  import { resolve } from 'node:path'
5
5
  import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
6
- import { serverMetrics } from '../index.js'
6
+ import { serverMetrics } from '../lib/server-metrics.js'
7
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
7
8
  import { isSileroAvailable } from '../lib/vad-silero.js'
8
9
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
9
10
  import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
@@ -133,7 +134,7 @@ healthRouter.get('/health', async (_req, res) => {
133
134
  // authenticated by the global /api middleware; ?refresh=1 forces discovery.
134
135
  healthRouter.get('/models', async (req, res) => {
135
136
  const catalog = await getCodexModelCatalog(req.query.refresh === '1')
136
- res.json(catalog)
137
+ res.json({ ...catalog, serverInstanceId: getServerInstanceId() })
137
138
  })
138
139
 
139
140
  // GET /api/cli-session — returns current CLI session ID for cross-device resume