@gotcos/glasses-server 6.50.2 → 6.50.3

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,8 @@
1
+ ## 6.50.3
2
+
3
+ - **The server referees between copies of the COS Glasses app.** 2026-09-18 10:06: the Even Realities app kept an older WebView copy of the plugin alive (hidden since at least 09:30) behind the one Miles opened, both answered one Start Meeting to the millisecond, and the same 90-minute meeting was recorded and HQ-polished twice. Glasses 6.9.505's claim in the Even app's storage could not stop it: the new copy read the old copy's claim 36 minutes stale, and the hidden copy never refreshed its claim or read the new one, while its HTTP calls to this server kept working (it uploaded all 827 chunks). `POST /api/client-instance/claim` `{ id, bootAt, version }` answers `{ verdict: 'owner' | 'yield', owner }`: the newest boot that is still posting owns the ring (ties by id); a claim from an older boot takes over only after the owner has been quiet for 45 s (`CLIENT_INSTANCE_LIVE_MS`, three 15 s ticks). `check: true` answers without taking, so a copy that already yielded can ask whether the owner is gone without stealing the ring after a phone lock froze both copies' timers. Behind the API token. In memory: a restart forgets the owner and the next posts re-establish it within a tick. Glasses 6.9.507 asks this route first and falls back to the storage claim when the Mac is unreachable or older.
4
+ - Tests (`client-instance.test.ts`): the arbitration executed (the zombie yields, the owner refreshes, the older reclaims one ms past the live edge, a same-millisecond tie has one owner, malformed claims refused) and over HTTP (state kept across requests, a 400 changes nothing); the mount sits after `requireApiToken` and is not a public path. Gate: `referee-dead-owner-never-released`, `referee-older-boot-wins`, `referee-not-mounted`, 3/3.
5
+
1
6
  ## 6.50.2
2
7
 
3
8
  - **The Turbo meeting preview no longer switches itself off for good.** Miles, 2026-09-18 08:43: the `~` provisional line on the lens was gone, Mac-reachable, Turbo configured. The preview route had answered "no text" to 5,719 requests since 2026-09-17 16:00 (in under a millisecond) while the Turbo sidecar itself transcribed the same audio in 0.27 s. One failed request (`The operation was aborted due to timeout`, then `fetch failed`, with the Mac under build load) set `previewAvailable = false`, and the only code that sets it back is the sidecar's own start, which runs once per server boot. Now a failure against a sidecar that is still running is a 10 s cooldown (`PREVIEW_FAILURE_COOLDOWN_MS`) and the next request after it tries again; five failures in a row (`PREVIEW_RECYCLE_AFTER_FAILURES`) recycle the sidecar; a sidecar that exited is restarted from the request path, at most once per 60 s window, doubling to 15 min while restarts keep failing and resetting on any answer. A missing model, a missing binary or a foreign listener on port 8177 is never restarted (a restart cannot fix it). After the server's own shutdown stop, nothing revives the sidecar (a request arriving during shutdown could otherwise have spawned one that outlived the process). Prompt previews were never affected (they fall back to the canonical worker); canonical meeting ASR never was. The preview log lines now carry an ISO timestamp: the lines that recorded this failure had none.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.50.2",
3
+ "version": "6.50.3",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -14,6 +14,7 @@ import { execSync } from 'node:child_process'
14
14
  import { randomBytes } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
+ import { createClientInstanceRouter } from './routes/client-instance.js'
17
18
  import { queryRouter } from './routes/query.js'
18
19
  import { providerProofRouter } from './routes/provider-proof.js'
19
20
  import { transcribeRouter } from './routes/transcribe.js'
@@ -530,6 +531,8 @@ const forkThreadForRoute = (request: {
530
531
  // API routes
531
532
  app.use('/api', healthRouter)
532
533
  app.use('/api', diagRouter)
534
+ // 6.50.3: the referee between copies of the COS Glasses app (routes/client-instance.ts).
535
+ app.use('/api', createClientInstanceRouter())
533
536
  app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
534
537
  prepareAdmission: preparePublicDurableQueryAdmission,
535
538
  }))
@@ -0,0 +1,60 @@
1
+ // Which copy of the COS Glasses app owns the ring (6.50.3). PURE: the clock is passed in.
2
+ //
3
+ // WHY THE SERVER. On 2026-09-18 the Even Realities app kept an older WebView copy of the
4
+ // plugin alive behind a newly opened one and delivered every ring event to both, so one
5
+ // Start Meeting recorded the same 90 minutes twice. Glasses 6.9.505 put a claim in the Even
6
+ // app's own storage: the newest boot wins, the copy behind it yields. It failed in the field
7
+ // because the hidden copy's storage calls went unanswered (its claim sat 36 min stale and it
8
+ // never read the newer one), while its HTTP calls to this server kept working (it uploaded
9
+ // all 827 chunks). This server is the one party every copy can still reach.
10
+ //
11
+ // RULE. One owner at a time. A claim from the owner refreshes it. A claim from a NEWER boot
12
+ // (ties broken by id) takes ownership. A claim from an older boot takes ownership only when
13
+ // the owner has gone quiet for CLIENT_INSTANCE_LIVE_MS (the newer copy died). In memory: a
14
+ // server restart forgets the owner, and the next posts re-establish it within one tick.
15
+
16
+ export const CLIENT_INSTANCE_TICK_MS = 15_000
17
+ /** Three missed ticks: the owner is gone. */
18
+ export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
19
+
20
+ export interface ClientInstanceClaim {
21
+ id: string
22
+ bootAt: number
23
+ version: string
24
+ }
25
+
26
+ export interface ClientInstanceOwner extends ClientInstanceClaim {
27
+ seenAt: number
28
+ }
29
+
30
+ export type ClientInstanceVerdict = 'owner' | 'yield'
31
+
32
+ const ID_PATTERN = /^[a-z0-9]{1,16}-[a-z0-9]{1,8}$/
33
+
34
+ /** A body the route may act on, or null. Never throws. */
35
+ export function parseClientInstanceClaim(body: unknown): ClientInstanceClaim | null {
36
+ if (!body || typeof body !== 'object') return null
37
+ const o = body as Record<string, unknown>
38
+ if (typeof o.id !== 'string' || !ID_PATTERN.test(o.id)) return null
39
+ if (typeof o.bootAt !== 'number' || !Number.isFinite(o.bootAt) || o.bootAt <= 0) return null
40
+ const version = typeof o.version === 'string' ? o.version.slice(0, 32) : ''
41
+ return { id: o.id, bootAt: o.bootAt, version }
42
+ }
43
+
44
+ function isNewer(a: ClientInstanceClaim, b: ClientInstanceClaim): boolean {
45
+ return a.bootAt > b.bootAt || (a.bootAt === b.bootAt && a.id > b.id)
46
+ }
47
+
48
+ /** The owner after `claim` arrives at `now`, and what to tell its sender. */
49
+ export function arbitrateClientInstance(
50
+ owner: ClientInstanceOwner | null,
51
+ claim: ClientInstanceClaim,
52
+ now: number,
53
+ ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
54
+ const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
55
+ if (!owner) return { owner: fresh, verdict: 'owner', took: true }
56
+ if (owner.id === claim.id) return { owner: { ...owner, seenAt: now }, verdict: 'owner', took: false }
57
+ if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
58
+ if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
59
+ return { owner, verdict: 'yield', took: false }
60
+ }
@@ -0,0 +1,35 @@
1
+ // POST /api/client-instance/claim — the referee between copies of the COS Glasses app (6.50.3).
2
+ // See lib/client-instance-claim.ts for the why. Behind the global API token like every /api
3
+ // route except health and diag. Body: { id, bootAt, version, check? }. Answer: { verdict, owner }.
4
+ //
5
+ // `check: true` answers without taking: a copy that already yielded asks whether the owner
6
+ // is still there, and must not steal the ring by asking (after a phone lock both copies'
7
+ // timers were frozen, so the owner only LOOKS quiet; the older copy's first tick would
8
+ // otherwise take it).
9
+
10
+ import { Router } from 'express'
11
+ import { arbitrateClientInstance, parseClientInstanceClaim, type ClientInstanceOwner } from '../lib/client-instance-claim.js'
12
+
13
+ export function createClientInstanceRouter(now: () => number = Date.now): Router {
14
+ const router = Router()
15
+ let owner: ClientInstanceOwner | null = null
16
+ router.post('/client-instance/claim', (req, res) => {
17
+ const claim = parseClientInstanceClaim(req.body)
18
+ if (!claim) return void res.status(400).json({ error: 'invalid_claim' })
19
+ const previous = owner
20
+ const result = arbitrateClientInstance(owner, claim, now())
21
+ if (req.body?.check === true) {
22
+ const shown = previous ?? result.owner
23
+ return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true })
24
+ }
25
+ owner = result.owner
26
+ if (result.took && previous && previous.id !== claim.id) {
27
+ console.log(`[client-instance] ${new Date(now()).toISOString()} ${claim.id} (${claim.version}) took the ring from ${previous.id}`)
28
+ }
29
+ res.json({
30
+ verdict: result.verdict,
31
+ owner: { id: owner.id, bootAt: owner.bootAt, version: owner.version, seenAt: owner.seenAt },
32
+ })
33
+ })
34
+ return router
35
+ }