@gotcos/glasses-server 6.21.0 → 6.21.2

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,3 +1,22 @@
1
+ ## 6.21.2
2
+
3
+ - Cache Python, Claude, Codex, and Cursor process probes for 30 seconds so the
4
+ public health endpoint remains cheap under frequent phone diagnostics. Stale
5
+ static versions are served while one background refresh runs; live recovery,
6
+ transcription, TTS, maintenance, and request fields remain fresh.
7
+ - Parse Cursor's actual `CLI Version` line instead of reporting the About
8
+ heading. Authenticated model discovery continues to be the authoritative
9
+ Cursor readiness signal.
10
+
11
+ ## 6.21.1
12
+
13
+ - Make the transactional Claude readiness proof explicitly use Haiku instead
14
+ of inheriting a user's heavyweight default model. The no-tool proof now has
15
+ a 45-second bound, while normal COS queries keep their selected models.
16
+ - Preserve timeout and cancellation reasons across the provider-process close
17
+ race. A timed-out proof now reports `provider proof timed out` instead of the
18
+ misleading `provider process exited before launch`.
19
+
1
20
  ## 6.21.0
2
21
 
3
22
  Transcription quality is now a machine-owned, observable two-tier policy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.0",
3
+ "version": "6.21.2",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,177 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { PYTHON_BIN } from './python-bridge.js'
3
+ import {
4
+ getCursorModelCatalog,
5
+ isCursorProviderReady,
6
+ resolveAgentBinary,
7
+ } from './cursor-model-catalog.js'
8
+
9
+ const DEFAULT_CACHE_TTL_MS = 30_000
10
+ const PROBE_TIMEOUT_MS = 5_000
11
+
12
+ export interface HealthStaticProbeSnapshot {
13
+ python: string
14
+ claude: string
15
+ codex: string
16
+ cursor: string
17
+ claudeAvailable: boolean
18
+ codexAvailable: boolean
19
+ cursorAvailable: boolean
20
+ }
21
+
22
+ interface CachedProbe<T> {
23
+ value: T
24
+ refreshedAt: number
25
+ }
26
+
27
+ /**
28
+ * Small stale-while-revalidate cache used for process-level health probes.
29
+ * Runtime fields (recordings, recovery leases, Whisper, TTS, request counts)
30
+ * remain fresh on every /api/health request.
31
+ */
32
+ export function createStaticProbeCache<T>(
33
+ load: () => Promise<T>,
34
+ ttlMs: () => number,
35
+ now: () => number = Date.now,
36
+ ): { get: () => Promise<T>; reset: () => void } {
37
+ let cached: CachedProbe<T> | null = null
38
+ let inFlight: Promise<T> | null = null
39
+
40
+ const refresh = (): Promise<T> => {
41
+ if (inFlight) return inFlight
42
+ inFlight = load().then((value) => {
43
+ cached = { value, refreshedAt: now() }
44
+ return value
45
+ }).finally(() => {
46
+ inFlight = null
47
+ })
48
+ return inFlight
49
+ }
50
+
51
+ return {
52
+ get: async () => {
53
+ if (!cached) return refresh()
54
+ if (now() - cached.refreshedAt < ttlMs()) return cached.value
55
+ // A stale static version string is safer than making liveness wait for
56
+ // four child processes. Refresh it for the next request in background.
57
+ void refresh().catch(() => {})
58
+ return cached.value
59
+ },
60
+ reset: () => {
61
+ cached = null
62
+ inFlight = null
63
+ },
64
+ }
65
+ }
66
+
67
+ function cacheTtlMs(): number {
68
+ const raw = Number(process.env.COS_HEALTH_STATIC_PROBE_TTL_MS ?? DEFAULT_CACHE_TTL_MS)
69
+ return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : DEFAULT_CACHE_TTL_MS
70
+ }
71
+
72
+ function execute(file: string, args: string[]): Promise<{ stdout: string; stderr: string }> {
73
+ return new Promise((resolve, reject) => {
74
+ execFile(file, args, {
75
+ encoding: 'utf8',
76
+ timeout: PROBE_TIMEOUT_MS,
77
+ maxBuffer: 256 * 1024,
78
+ killSignal: 'SIGKILL',
79
+ }, (error, stdout, stderr) => {
80
+ if (error) return reject(error)
81
+ resolve({ stdout: String(stdout), stderr: String(stderr) })
82
+ })
83
+ })
84
+ }
85
+
86
+ export function parseCursorAboutVersion(output: string): string | undefined {
87
+ for (const rawLine of output.split(/\r?\n/)) {
88
+ const line = rawLine.trim()
89
+ const match = /^CLI Version\s*:?\s*(\d{4}(?:\.\d+){1,3}(?:[-+][a-z0-9.-]+)?)$/i.exec(line)
90
+ if (match) return match[1]
91
+ }
92
+ return undefined
93
+ }
94
+
95
+ function firstNonemptyLine(output: string): string | undefined {
96
+ return output.split(/\r?\n/).map(line => line.trim()).find(Boolean)
97
+ }
98
+
99
+ async function probePython(): Promise<string> {
100
+ if (!PYTHON_BIN) return 'standalone'
101
+ try {
102
+ return firstNonemptyLine((await execute(PYTHON_BIN, ['--version'])).stdout) ?? 'available'
103
+ } catch {
104
+ return 'error'
105
+ }
106
+ }
107
+
108
+ async function probeClaude(): Promise<{ value: string; available: boolean }> {
109
+ try {
110
+ const result = await execute('claude', ['--version'])
111
+ return { value: firstNonemptyLine(result.stdout) ?? 'available', available: true }
112
+ } catch {
113
+ return { value: 'error', available: false }
114
+ }
115
+ }
116
+
117
+ async function probeCodex(): Promise<{ value: string; available: boolean }> {
118
+ try {
119
+ const result = await execute('codex', ['--version'])
120
+ const combined = `${result.stdout}\n${result.stderr}`.trim()
121
+ const versionLine = combined.split(/\r?\n/).map(line => line.trim())
122
+ .find(line => /^codex(?:-cli)?\s+/i.test(line))
123
+ return { value: versionLine ?? firstNonemptyLine(combined) ?? 'available', available: true }
124
+ } catch {
125
+ return { value: 'error', available: false }
126
+ }
127
+ }
128
+
129
+ async function probeCursor(): Promise<{ value: string; available: boolean }> {
130
+ const agentBinary = resolveAgentBinary()
131
+ if (!agentBinary) return { value: 'error', available: false }
132
+ try {
133
+ const result = await execute(agentBinary, ['about'])
134
+ const combined = `${result.stdout}\n${result.stderr}`.trim()
135
+ const version = parseCursorAboutVersion(combined)
136
+ // Catalog discovery is authenticated downstream truth and can take up to
137
+ // seven seconds. Warm it without putting that latency on public health.
138
+ void getCursorModelCatalog().catch(() => {})
139
+ const available = isCursorProviderReady()
140
+ const value = version ?? 'available'
141
+ return {
142
+ value: available ? value : `${value} (models unresolved)`,
143
+ available,
144
+ }
145
+ } catch {
146
+ return { value: 'error', available: false }
147
+ }
148
+ }
149
+
150
+ async function loadStaticHealthProbes(): Promise<HealthStaticProbeSnapshot> {
151
+ const [python, claude, codex, cursor] = await Promise.all([
152
+ probePython(),
153
+ probeClaude(),
154
+ probeCodex(),
155
+ probeCursor(),
156
+ ])
157
+ return {
158
+ python,
159
+ claude: claude.value,
160
+ codex: codex.value,
161
+ cursor: cursor.value,
162
+ claudeAvailable: claude.available,
163
+ codexAvailable: codex.available,
164
+ cursorAvailable: cursor.available,
165
+ }
166
+ }
167
+
168
+ const staticProbeCache = createStaticProbeCache(loadStaticHealthProbes, cacheTtlMs)
169
+
170
+ export function getHealthStaticProbes(): Promise<HealthStaticProbeSnapshot> {
171
+ return staticProbeCache.get()
172
+ }
173
+
174
+ /** Test hook. */
175
+ export function _resetHealthStaticProbeCache(): void {
176
+ staticProbeCache.reset()
177
+ }
@@ -17,7 +17,7 @@ const PROOF_PROMPT = `This is an automated local readiness check. Do not use too
17
17
  const successCache = new Map<ProofProvider, ProviderProofResult>()
18
18
  const inFlight = new Map<ProofProvider, Promise<ProviderProofResult>>()
19
19
 
20
- interface ProcessResult {
20
+ export interface ProcessResult {
21
21
  code: number | null
22
22
  stdout: string
23
23
  stderr: string
@@ -25,7 +25,9 @@ interface ProcessResult {
25
25
  aborted: boolean
26
26
  }
27
27
 
28
- function runBounded(
28
+ type TerminationReason = 'timeout' | 'abort' | null
29
+
30
+ export function runBounded(
29
31
  command: string,
30
32
  args: string[],
31
33
  input: string,
@@ -48,6 +50,11 @@ function runBounded(
48
50
  let stdout = ''
49
51
  let stderr = ''
50
52
  let settled = false
53
+ // The child `close` event normally wins the race against the async
54
+ // terminateProviderProcess() result. Record WHY termination began before
55
+ // sending a signal so that close cannot misreport a timeout as
56
+ // "exited before launch" (Control 0.3.5 field failure, 2026-08-03).
57
+ let terminationReason: TerminationReason = null
51
58
  const cap = (value: string) => value.slice(-256_000)
52
59
  child.stdout.on('data', chunk => { stdout = cap(stdout + chunk.toString()) })
53
60
  child.stderr.on('data', chunk => { stderr = cap(stderr + chunk.toString()) })
@@ -58,27 +65,57 @@ function runBounded(
58
65
  signal?.removeEventListener('abort', abort)
59
66
  resolvePromise(result)
60
67
  }
68
+ const terminatedResult = (code: number | null): ProcessResult => ({
69
+ code,
70
+ stdout,
71
+ stderr,
72
+ timedOut: terminationReason === 'timeout',
73
+ aborted: terminationReason === 'abort',
74
+ })
61
75
  const timer = setTimeout(() => {
76
+ if (settled || terminationReason) return
77
+ terminationReason = 'timeout'
62
78
  void terminateProviderProcess(child, { termGraceMs: 50 }).then(result => {
63
- if (result.closed) finish({ code: result.code, stdout, stderr, timedOut: true, aborted: false })
79
+ if (result.closed) finish(terminatedResult(result.code))
64
80
  else console.error('[provider-proof] timed-out provider did not close after SIGKILL; retaining request ownership')
65
81
  })
66
82
  }, timeoutMs)
67
83
  timer.unref?.()
68
84
  const abort = () => {
85
+ if (settled || terminationReason) return
86
+ terminationReason = 'abort'
69
87
  void terminateProviderProcess(child).then(result => {
70
- if (result.closed) finish({ code: result.code, stdout, stderr, timedOut: false, aborted: true })
88
+ if (result.closed) finish(terminatedResult(result.code))
71
89
  else console.error('[provider-proof] canceled provider did not close after SIGKILL; retaining request ownership')
72
90
  })
73
91
  }
74
- child.once('error', err => finish({ code: null, stdout, stderr: err.message, timedOut: false, aborted: false }))
75
- child.once('close', code => finish({ code, stdout, stderr, timedOut: false, aborted: false }))
92
+ child.once('error', err => {
93
+ stderr = cap(stderr + err.message)
94
+ finish(terminatedResult(null))
95
+ })
96
+ child.once('close', code => finish(terminatedResult(code)))
76
97
  signal?.addEventListener('abort', abort, { once: true })
77
98
  child.stdin.on('error', () => { /* close/error is authoritative */ })
78
99
  child.stdin.end(input)
79
100
  })
80
101
  }
81
102
 
103
+ export const CLAUDE_PROOF_MODEL = 'haiku'
104
+ export const CLAUDE_PROOF_TIMEOUT_MS = 45_000
105
+
106
+ export function claudeProofArgs(): string[] {
107
+ return [
108
+ '-p',
109
+ '--model', CLAUDE_PROOF_MODEL,
110
+ '--output-format', 'json',
111
+ '--permission-mode', 'dontAsk',
112
+ '--tools', '',
113
+ '--allowedTools', '',
114
+ '--system-prompt', PROOF_PROMPT,
115
+ PROOF_PROMPT,
116
+ ]
117
+ }
118
+
82
119
  export function claudeProofText(stdout: string): string {
83
120
  try {
84
121
  const parsed = JSON.parse(stdout) as { result?: unknown }
@@ -120,15 +157,7 @@ function safeProofError(result: ProcessResult): string {
120
157
  async function executeProof(provider: ProofProvider, signal?: AbortSignal): Promise<ProviderProofResult> {
121
158
  const started = Date.now()
122
159
  const result = provider === 'claude'
123
- ? await runBounded('claude', [
124
- '-p',
125
- '--output-format', 'json',
126
- '--permission-mode', 'dontAsk',
127
- '--tools', '',
128
- '--allowedTools', '',
129
- '--system-prompt', PROOF_PROMPT,
130
- PROOF_PROMPT,
131
- ], '', 120_000, signal)
160
+ ? await runBounded('claude', claudeProofArgs(), '', CLAUDE_PROOF_TIMEOUT_MS, signal)
132
161
  : await runBounded('codex', [
133
162
  'exec',
134
163
  '--sandbox', 'read-only',
@@ -1,8 +1,7 @@
1
1
  import { Router } from 'express'
2
- import { execFile } from 'node:child_process'
3
2
  import { statSync } from 'node:fs'
4
3
  import { resolve } from 'node:path'
5
- import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
4
+ import { COS_SCRIPTS_DIR, COS_MODE } from '../lib/python-bridge.js'
6
5
  import { serverMetrics } from '../lib/server-metrics.js'
7
6
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
7
  import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
@@ -24,7 +23,6 @@ import {
24
23
  getCursorModelCatalog,
25
24
  getCursorModelCatalogSnapshot,
26
25
  isCursorProviderReady,
27
- resolveAgentBinary,
28
26
  } from '../lib/cursor-model-catalog.js'
29
27
  import { isMediaProcessingReady } from '../lib/image-safety.js'
30
28
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
@@ -41,6 +39,7 @@ import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
41
39
  import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
42
40
  import { getWhisperPreviewCapability } from '../lib/whisper-preview.js'
43
41
  import { getTranscriptionProfileStatus } from '../lib/profile.js'
42
+ import { getHealthStaticProbes } from '../lib/health-static-probes.js'
44
43
 
45
44
  export const healthRouter = Router()
46
45
 
@@ -74,101 +73,26 @@ function durableQueryJobStatus() {
74
73
  }
75
74
 
76
75
  healthRouter.get('/health', async (_req, res) => {
77
- await refreshLocalTtsHealth()
76
+ const [, staticProbes] = await Promise.all([
77
+ refreshLocalTtsHealth(),
78
+ getHealthStaticProbes(),
79
+ ])
78
80
  const checks: Record<string, string | number | boolean> = {
79
81
  status: 'ok',
80
82
  mode: COS_MODE ? 'cos' : 'standalone',
81
83
  server: 'ok',
82
- python: 'unknown',
83
- claude: 'unknown',
84
- codex: 'unknown',
85
- cursor: 'unknown',
84
+ python: staticProbes.python,
85
+ claude: staticProbes.claude,
86
+ codex: staticProbes.codex,
87
+ cursor: staticProbes.cursor,
86
88
  uptime_seconds: Math.floor((Date.now() - serverMetrics.startedAt) / 1000),
87
89
  request_count: serverMetrics.requestCount,
88
90
  }
89
91
 
90
92
  // Feature detection flags
91
- let claudeAvailable = false
92
- let codexAvailable = false
93
- let cursorAvailable = false
94
-
95
- // Check Python venv (COS mode only)
96
- if (PYTHON_BIN) {
97
- try {
98
- await new Promise<void>((resolve, reject) => {
99
- execFile(PYTHON_BIN!, ['--version'], { timeout: 5000 }, (err, stdout) => {
100
- if (err) return reject(err)
101
- checks.python = stdout.trim()
102
- resolve()
103
- })
104
- })
105
- } catch {
106
- checks.python = 'error'
107
- }
108
- } else {
109
- checks.python = 'standalone'
110
- }
111
-
112
- // Check claude CLI
113
- try {
114
- await new Promise<void>((resolve, reject) => {
115
- execFile('claude', ['--version'], { timeout: 5000 }, (err, stdout) => {
116
- if (err) return reject(err)
117
- checks.claude = stdout.trim()
118
- claudeAvailable = true
119
- resolve()
120
- })
121
- })
122
- } catch {
123
- checks.claude = 'error'
124
- }
125
-
126
- // Check Codex CLI. The desktop CLI can print benign PATH warnings to stderr,
127
- // so version extraction uses stdout + stderr and looks for the codex-cli line.
128
- try {
129
- await new Promise<void>((resolve, reject) => {
130
- execFile('codex', ['--version'], { timeout: 5000 }, (err, stdout, stderr) => {
131
- if (err) return reject(err)
132
- const combined = `${stdout}\n${stderr}`.trim()
133
- const versionLine = combined.split('\n').map(line => line.trim()).find(line => /^codex(?:-cli)?\s+/i.test(line))
134
- checks.codex = versionLine ?? combined.split('\n')[0] ?? 'available'
135
- codexAvailable = true
136
- resolve()
137
- })
138
- })
139
- } catch {
140
- checks.codex = 'error'
141
- }
142
-
143
- // Cursor Agent CLI — probe via `agent about` only (≤5s). Never use
144
- // `agent status` (can hang on login UX while logged out).
145
- try {
146
- const agentBinary = resolveAgentBinary()
147
- if (!agentBinary) {
148
- checks.cursor = 'error'
149
- } else {
150
- await new Promise<void>((resolveCheck, reject) => {
151
- execFile(agentBinary, ['about'], { timeout: 5000 }, (err, stdout, stderr) => {
152
- if (err) return reject(err)
153
- const combined = `${stdout}\n${stderr}`.trim()
154
- const versionLine = combined.split('\n').map(line => line.trim()).find(line =>
155
- /CLI Version|cursor|agent/i.test(line),
156
- )
157
- checks.cursor = versionLine ?? combined.split('\n')[0] ?? 'available'
158
- resolveCheck()
159
- })
160
- })
161
- await getCursorModelCatalog()
162
- cursorAvailable = isCursorProviderReady()
163
- if (!cursorAvailable) {
164
- checks.cursor = typeof checks.cursor === 'string' && checks.cursor !== 'error'
165
- ? `${checks.cursor} (models unresolved)`
166
- : 'error'
167
- }
168
- }
169
- } catch {
170
- checks.cursor = 'error'
171
- }
93
+ const claudeAvailable = staticProbes.claudeAvailable
94
+ const codexAvailable = staticProbes.codexAvailable
95
+ const cursorAvailable = staticProbes.cursorAvailable
172
96
 
173
97
  // Check session cache freshness (COS mode only)
174
98
  if (COS_SCRIPTS_DIR) {