@gotcos/glasses-server 6.9.0 → 6.11.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
@@ -28,6 +28,14 @@ BIND_HOST=0.0.0.0
28
28
  # server after Wi-Fi/Tailscale changes and process restarts.
29
29
  # COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
30
30
 
31
+ # Build 204+ can opt into server-owned durable query jobs. The server fsyncs an
32
+ # accepted prompt before returning 202, then keeps provider work running if the
33
+ # phone backgrounds, reloads, or changes networks. Reopening COS reattaches to
34
+ # the same job. Set to 0 (or remove) to send NEW prompts through the legacy
35
+ # streaming route; already accepted durable jobs remain readable/cancellable
36
+ # and drain safely.
37
+ # COS_DURABLE_QUERY_JOBS=1
38
+
31
39
  # ── THE LLM (chat) ──────────────────────────────────────────────────────
32
40
  # Chat runs through your LOCAL agent CLI — NOT an API key:
33
41
  # Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.11.0
4
+
5
+ Local-first meeting recovery for COS Glasses build 209+.
6
+
7
+ - **Record through network loss.** The server advertises a versioned
8
+ `localFirstMeetings` capability with its stable instance ID. Compatible
9
+ clients can keep audio locally, reconnect to the same server, and reconcile
10
+ the exact sparse set of chunks it durably received.
11
+ - **Durable means acknowledged.** Raw meeting WAVs and the received-index
12
+ ledger are committed atomically before a chunk receives success. Storage
13
+ failures return typed retryable errors; capacity exhaustion returns `507`
14
+ instead of silently discarding audio.
15
+ - **Long meetings stay alive.** Active-session retention is measured from the
16
+ last durable activity, not the meeting start time, so recordings longer than
17
+ four hours are not mistaken for abandoned sessions.
18
+ - **Safe reconnect and close.** Authenticated session-status responses expose
19
+ exact compressed receive ranges, retention, and closed/saved state. Durable
20
+ tombstones prevent a late or replaying client from recreating a completed
21
+ meeting after a restart.
22
+ - **Idempotent finalization.** Repeating `POST /api/meeting/save` for an already
23
+ saved session returns the original versioned receipt and filename without
24
+ creating a second meeting.
25
+ - **Backward compatible.** Existing live transcription, meeting save, prompt
26
+ recovery, durable queries, and older clients retain their prior routes and
27
+ fields. The new capability, receipt fields, and status route are additive.
28
+
29
+ ## 6.10.0
30
+
31
+ Opt-in server-owned durable query jobs for COS Glasses build 204+.
32
+
33
+ - **Accepted means durable.** With `COS_DURABLE_QUERY_JOBS=1`, the server
34
+ appends and fsyncs an immutable job before returning 202. Provider execution
35
+ is no longer owned by the phone's current request, WebView, or SSE subscriber.
36
+ - **Reconnect without duplication.** The client can recover an ambiguous
37
+ admission by its stable client job ID, replay ordered bounded events, and
38
+ acknowledge one terminal projection idempotently after message, queue,
39
+ counter, and session state are durable on the phone.
40
+ - **Crash and cancellation fences.** Provider ownership is persisted before
41
+ input, session-scoped leases prevent overlapping orphan continuations after a
42
+ restart, cancellation is durable, and answer-ready ownership gates
43
+ conversation, image, notification, and Done side effects.
44
+ - **Private bounded storage.** The append-only journal uses private directory
45
+ and file modes, repairs torn tails, bounds progress/activity payloads, and
46
+ retains terminal jobs for exactly seven days.
47
+ - **Safe rollout and rollback.** The health capability advertises exact protocol
48
+ version 1 only when configured and the store is ready. Removing the flag
49
+ blocks new durable admissions but leaves GET/events/cancel/ack available so
50
+ accepted jobs drain; legacy queries, first turns, handoffs, and older clients
51
+ remain unchanged.
52
+
3
53
  ## 6.9.0
4
54
 
5
55
  Live recoverable prompt transcription for COS Glasses builds 200+.
package/README.md CHANGED
@@ -56,6 +56,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
56
56
  ## What it does
57
57
 
58
58
  - Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
59
+ - With COS Glasses build 204+, opt into server-owned durable queries with
60
+ `COS_DURABLE_QUERY_JOBS=1`: accepted work survives phone backgrounding,
61
+ WebView reloads, and network handoffs, then reattaches without duplicate work
62
+ or duplicate replies
59
63
  - Choose Opus, Fable, Sonnet, GPT Frontier, or GPT Balanced plus High, Extra
60
64
  High, Max, or Ultracode effort; optional redacted tool activity streams only
61
65
  to the authenticated query that requested it
@@ -68,6 +72,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
68
72
  compatible app builds, their warm transcript also appears live while speaking;
69
73
  final HQ transcription remains authoritative.
70
74
  - Live voice capture + transcription during meetings
75
+ - With COS Glasses build 209+ and server 6.11.0+, meetings continue recording
76
+ locally through a network interruption. Reconnecting reconciles the exact
77
+ chunks already stored by the Mac, uploads only missing audio, and finalizes
78
+ through an idempotent save receipt without duplicating the meeting.
71
79
  - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
72
80
  - Tasks / calendar / people context **if** you run the
73
81
  [COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
@@ -78,7 +86,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
78
86
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
79
87
  optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
80
88
  `COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
81
- `COS_SCRIPTS_DIR` (full pipeline), and `COS_MEDIA_ROOT` (optional image-store
89
+ `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
90
+ server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
82
91
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
83
92
  `~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
84
93
 
@@ -99,6 +108,15 @@ BIND_HOST=0.0.0.0 npm run start:server
99
108
  - *Voice getting billed?* — install `whisper-cpp` for free local transcription.
100
109
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
101
110
  - *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
111
+ - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
112
+ `COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
113
+ `features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
114
+ back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
115
+ - *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
116
+ Restart once, then confirm `/api/health` reports
117
+ `features.localFirstMeetings: true` and
118
+ `capabilities.localFirstMeetings.protocolVersion: 1`. Older app builds keep
119
+ using their existing live-transcription and meeting-save paths.
102
120
 
103
121
  ## License
104
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.9.0",
3
+ "version": "6.11.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
@@ -43,6 +43,13 @@ import { getMediaStore } from './lib/media-store.js'
43
43
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
44
44
  import { serverMetrics } from './lib/server-metrics.js'
45
45
  import { initializeServerInstanceId } from './lib/server-instance-id.js'
46
+ import { createQueryJobsRouter } from './routes/query-jobs.js'
47
+ import {
48
+ initQueryJobRuntime,
49
+ preparePublicDurableQueryAdmission,
50
+ queryJobCoordinator,
51
+ shutdownQueryJobRuntime,
52
+ } from './lib/query-job-runtime.js'
46
53
 
47
54
  const app = express()
48
55
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -139,6 +146,9 @@ app.use((_req, _res, next) => {
139
146
  // API routes
140
147
  app.use('/api', healthRouter)
141
148
  app.use('/api', diagRouter)
149
+ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
150
+ prepareAdmission: preparePublicDurableQueryAdmission,
151
+ }))
142
152
  app.use('/api', queryRouter)
143
153
  app.use('/api', transcribeRouter)
144
154
  app.use('/api', displayRouter)
@@ -173,21 +183,28 @@ app.get('/', (_req, res) => {
173
183
  )
174
184
  })
175
185
 
176
- // Graceful shutdown stop whisper-server child process
177
- process.on('SIGTERM', () => {
178
- // Production stops (kill, service managers) send SIGTERM — flush session logs
179
- // exactly like SIGINT so active conversations aren't lost on shutdown.
186
+ // Graceful shutdown persists an interrupted terminal before provider abort.
187
+ // The bounded force-exit keeps service managers from hanging forever on a
188
+ // broken disk while retaining the previous session/catalog/Whisper cleanup.
189
+ let gracefulShutdownStarted = false
190
+ async function gracefulShutdown(): Promise<void> {
191
+ if (gracefulShutdownStarted) return
192
+ gracefulShutdownStarted = true
193
+ const forceExit = setTimeout(() => process.exit(1), 8_000)
194
+ forceExit.unref?.()
195
+ try {
196
+ await shutdownQueryJobRuntime('server_shutdown')
197
+ } catch (error) {
198
+ console.error('[query-jobs] graceful interruption failed:', error)
199
+ }
180
200
  try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
181
201
  stopCodexModelCatalogRefresh()
182
202
  stopWhisperServer()
203
+ clearTimeout(forceExit)
183
204
  process.exit(0)
184
- })
185
- process.on('SIGINT', () => {
186
- logActiveSessionsOnShutdown()
187
- stopCodexModelCatalogRefresh()
188
- stopWhisperServer()
189
- process.exit(0)
190
- })
205
+ }
206
+ process.on('SIGTERM', () => { void gracefulShutdown() })
207
+ process.on('SIGINT', () => { void gracefulShutdown() })
191
208
 
192
209
  // Crash protection for runtime work. Listener failures are handled separately
193
210
  // and exit immediately so a supervisor can restart a clean, unified process.
@@ -228,6 +245,18 @@ listenRequiredServers(listeners).then(() => {
228
245
  console.log(`[COS API] Server instance: ${serverInstanceId}`)
229
246
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
230
247
 
248
+ void initQueryJobRuntime().then(health => {
249
+ if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
250
+ console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
251
+ } else {
252
+ console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
253
+ }
254
+ }).catch(error => {
255
+ // The store remains degraded and rejects admission. Legacy /api/query is
256
+ // still mounted, so disabling the feature flag is an immediate rollback.
257
+ console.error('[COS API] Durable query-job store unavailable:', error)
258
+ })
259
+
231
260
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
232
261
  // not paste-able — enumerate real interfaces and label the Tailscale one.
233
262
  try {
@@ -38,6 +38,7 @@ import {
38
38
  type ActivityPreviewLine,
39
39
  } from './activity-preview.js'
40
40
  import {
41
+ collectRunOutputImagesBounded,
41
42
  createRunOutputImagePublisher,
42
43
  isRunOutputImagePublisherCommand,
43
44
  type RunOutputImageCollectionStats,
@@ -291,19 +292,34 @@ function isExtendedQuery(query: string): boolean {
291
292
  }
292
293
 
293
294
  export interface ModelRunMetadata {
295
+ claudeRunId?: string
296
+ clientJobId?: string
297
+ generation?: number
294
298
  codexRunId?: string
295
299
  codexThreadId?: string
296
300
  outputAttachments?: MediaAttachmentRef[]
297
301
  outputImageStats?: RunOutputImageCollectionStats
298
302
  }
299
303
 
304
+ /** Public-safe provider launch metadata for durable job coordination. It
305
+ * deliberately exposes no ChildProcess object, kill handle, paths, or env. */
306
+ export interface ProviderProcessMetadata {
307
+ provider: 'claude' | 'codex'
308
+ runId: string
309
+ pid?: number
310
+ clientJobId?: string
311
+ generation?: number
312
+ }
313
+
300
314
  export interface StreamCallbacks {
301
315
  onChunk: (text: string) => void
302
- onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => void
303
- onError: (error: string) => void
316
+ onAnswerReady?: (fullText: string) => boolean | void | Promise<boolean | void>
317
+ onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => boolean | void | Promise<boolean | void>
318
+ onError: (error: string) => void | Promise<void>
304
319
  onToolStatus?: (toolName: string) => void
305
320
  onActivityLine?: (line: ActivityPreviewLine) => void
306
321
  onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
322
+ onProviderProcess?: (metadata: ProviderProcessMetadata) => boolean | void | Promise<boolean | void>
307
323
  }
308
324
 
309
325
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
@@ -336,6 +352,10 @@ export interface CallOptions {
336
352
  lightweight?: boolean // Skip async context fetch — use cached context instantly (G2 speed path)
337
353
  abortSignal?: AbortSignal
338
354
  effort?: EffortPreference
355
+ clientJobId?: string
356
+ generation?: number
357
+ /** Durable coordinator already owns the per-session provider lease. */
358
+ sessionLockHeld?: boolean
339
359
  }
340
360
 
341
361
  export async function callClaudeStreaming(
@@ -367,7 +387,10 @@ export async function callClaudeStreaming(
367
387
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
368
388
  const resolvedCliKey = cliSessionKey(sid, resolvedModel)
369
389
  let existingCliSession = cliSessionMap.get(resolvedCliKey)
370
- callbacks.onStart?.(resolvedModel, sid, existingCliSession)
390
+ callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
391
+ clientJobId: options?.clientJobId,
392
+ generation: options?.generation,
393
+ })
371
394
 
372
395
  // Phase: context loading (skipped in lightweight mode)
373
396
  let phase: Phase = 'context'
@@ -414,7 +437,18 @@ export async function callClaudeStreaming(
414
437
  // Record user message (with [Photo]/[N Photos] prefix for vision queries)
415
438
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
416
439
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
417
- const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
440
+ const exchangeProvenance = {
441
+ clientJobId: options?.clientJobId,
442
+ generation: options?.generation,
443
+ }
444
+ const pendingUserExchange = addExchange(
445
+ sid,
446
+ 'user',
447
+ historyQuery,
448
+ globalMsgNum,
449
+ undefined,
450
+ exchangeProvenance,
451
+ )
418
452
 
419
453
  // Vision queries need the Read tool to see the image files
420
454
  const baseTools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
@@ -519,15 +553,62 @@ export async function callClaudeStreaming(
519
553
  cleanupModelImageInputs(imageInputs)
520
554
  }
521
555
 
556
+ function abandonLostDurableOwnership(message: string) {
557
+ finalized = true
558
+ cleanup()
559
+ cleanupImages()
560
+ outputImagePublisher?.cleanup()
561
+ removeExchange(sid, pendingUserExchange)
562
+ if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
563
+ finishClaudeRun(run.runId, {
564
+ status: 'failed',
565
+ startedAtMs: startTime,
566
+ error: message,
567
+ exitCode: null,
568
+ })
569
+ }
570
+
522
571
  async function finalize(text: string) {
523
572
  if (finalized) return
524
573
  finalized = true
525
574
  cleanup()
526
575
  cleanupImages()
527
576
 
528
- // Persist text before output-image normalization. A daemon crash during
529
- // finalization cannot erase an otherwise successful answer.
530
- const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
577
+ // The coordinator persists the final provider text before conversation
578
+ // mutation, condensation, or output-image normalization can stall/crash.
579
+ try {
580
+ const answerOwned = await callbacks.onAnswerReady?.(text)
581
+ if (answerOwned === false) {
582
+ abandonLostDurableOwnership('claude-bridge: durable answer ownership was lost.')
583
+ return
584
+ }
585
+ } catch (error) {
586
+ console.error('[claude-bridge] durable answer barrier failed:', error)
587
+ outputImagePublisher?.cleanup()
588
+ removeExchange(sid, pendingUserExchange)
589
+ if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
590
+ finishClaudeRun(run.runId, {
591
+ status: 'failed',
592
+ startedAtMs: startTime,
593
+ error: 'claude-bridge: durable answer persistence failed.',
594
+ exitCode: null,
595
+ })
596
+ try {
597
+ await callbacks.onError('claude-bridge: durable answer persistence failed.')
598
+ } catch (callbackError) {
599
+ console.error('[claude-bridge] durable barrier error callback failed:', callbackError)
600
+ }
601
+ return
602
+ }
603
+
604
+ const assistantExchange = addExchange(
605
+ sid,
606
+ 'assistant',
607
+ text,
608
+ globalMsgNum,
609
+ undefined,
610
+ exchangeProvenance,
611
+ )
531
612
  if (imagePaths.length > 0) {
532
613
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
533
614
  }
@@ -539,7 +620,9 @@ export async function callClaudeStreaming(
539
620
  const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
540
621
  preparingHeartbeat.unref?.()
541
622
  try {
542
- outputAttachments = await outputImagePublisher.collect()
623
+ outputAttachments = await collectRunOutputImagesBounded(outputImagePublisher, {
624
+ signal: options?.abortSignal,
625
+ })
543
626
  } catch (err) {
544
627
  console.error('[claude-bridge] output image collection failed:', err)
545
628
  } finally {
@@ -576,10 +659,14 @@ export async function callClaudeStreaming(
576
659
  exitCode: 0,
577
660
  })
578
661
 
579
- callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
662
+ const terminalOwned = await callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
663
+ claudeRunId: run.runId,
664
+ clientJobId: options?.clientJobId,
665
+ generation: options?.generation,
580
666
  ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
581
667
  ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
582
668
  })
669
+ if (terminalOwned === false) return
583
670
 
584
671
  // Telegram notifications — fire and forget
585
672
  if (isFirstQuery) {
@@ -589,7 +676,7 @@ export async function callClaudeStreaming(
589
676
  notifyExchange(sid, query, text)
590
677
  }
591
678
 
592
- function finalizeError(
679
+ async function finalizeError(
593
680
  msg: string,
594
681
  exitCode?: number | null,
595
682
  status: Exclude<ClaudeRunStatus, 'running'> = 'failed',
@@ -610,7 +697,7 @@ export async function callClaudeStreaming(
610
697
  error: msg,
611
698
  exitCode,
612
699
  })
613
- callbacks.onError(msg)
700
+ await callbacks.onError(msg)
614
701
  }
615
702
 
616
703
  function handleAbort() {
@@ -827,11 +914,27 @@ export async function callClaudeStreaming(
827
914
  ? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
828
915
  : fullQuery
829
916
  try {
917
+ const providerOwned = await callbacks.onProviderProcess?.({
918
+ provider: 'claude',
919
+ runId: run.runId,
920
+ pid: proc.pid,
921
+ clientJobId: options?.clientJobId,
922
+ generation: options?.generation,
923
+ })
924
+ if (providerOwned === false) {
925
+ proc.kill('SIGTERM')
926
+ abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
927
+ return sid
928
+ }
929
+ if (finalized) return sid
830
930
  proc.stdin.write(cliQuery)
831
931
  proc.stdin.end()
832
932
  } catch (err) {
833
933
  const message = err instanceof Error ? err.message : String(err)
834
- finalizeError(`claude-bridge: stdin failed — ${message}`, null)
934
+ if (!finalized) {
935
+ proc.kill('SIGTERM')
936
+ await finalizeError(`claude-bridge: provider start failed — ${message}`, null)
937
+ }
835
938
  }
836
939
 
837
940
  return sid