@gotcos/glasses-server 6.52.0 → 6.52.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,22 @@
1
+ ## 6.52.3
2
+
3
+ ### A quiet ring stays with its owner; a zombie never takes it on waking
4
+
5
+ 2026-09-20: a hidden copy of the glasses app from the night before took the ring after the live copy went quiet, and answered a hold-to-talk 27 minutes later with a stale session. Under 6.50.4 an older boot could take a ring whose owner had been quiet for 45 s, and a locked phone is quiet for longer than that.
6
+
7
+ - **An older boot takes a quiet ring back only after ten minutes** (`CLIENT_INSTANCE_RECLAIM_MS`), **and only while it has itself been claiming** (`CLIENT_INSTANCE_AWAKE_MS`, 45 s: its own previous claim must be that recent). Both copies sleep when the phone does, so a zombie that wakes beside the live copy has a gap of its own and is refused; the live copy's refresh then lands within a tick. A copy never seen before is not awake. A NEWER boot still takes at once, however quiet the owner: reopening COS remains the fix for a dead copy. The route remembers each copy's last claim time (`CLIENT_INSTANCE_MAX_SEEN` 64, least recent dropped).
8
+ - Everything else in the referee is 6.50.4: one owner per device, the recording holds (own tagged chunks, `recording` in the claim), `check: true` never takes, no protocol change. Glasses 6.9.510 and 6.9.516 see only the longer takeover window. No new client is required; glasses 6.9.518 adds the matching client-side check.
9
+ - **The 6.52.1 Codex fixtures now expect `turn_started_at`.** 6.52.1 stamped the prompt line with the user record's timestamp and left three `session-stream-codex` expectations on the old shape, so the suite was red. The behaviour is unchanged; the expectations were updated.
10
+ - Tests: the reclaim edge (10 min, awake), the overnight zombie (hours-old own claim, never seen), the awake edge to the millisecond, the newer boot still taking, and over HTTP: checks past the window say owner without taking, a first claim after sleep is refused and the next tick takes. Gate: `referee-zombie-reclaims-after-45s`, `referee-asleep-zombie-reclaims`, `referee-never-seen-counts-as-awake`, `referee-last-seen-unnoted`.
11
+
12
+ ## 6.52.1
13
+
14
+ ### Session turn clock
15
+
16
+ Sessions prompt events now carry additive `turn_started_at` (ISO), the timestamp on the user record that started the turn. Seed, reconnect, and live frames keep that value; transport `at` is still `Date.now()` at emit time, so a reconnect no longer resets the glasses elapsed clock. `/api/models` advertises `capabilities.runningTurnMeta.protocolVersion = 1`. A 6.52.0 client strips the unknown field and renders as before. Messages jobs already have durable `startedAt`; this is Sessions only.
17
+
18
+ - Tests: a Claude user row with `timestamp` stamps the prompt draft; a row without one is byte-identical to 6.52.0; the models capability is present beside `sessionQuestions`.
19
+
1
20
  ## 6.52.0
2
21
 
3
22
  ### Messages trail
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.52.0",
3
+ "version": "6.52.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": {
@@ -16,6 +16,20 @@
16
16
  export const CLIENT_INSTANCE_TICK_MS = 15_000
17
17
  /** Three missed ticks: the owner is gone. */
18
18
  export const CLIENT_INSTANCE_LIVE_MS = 3 * CLIENT_INSTANCE_TICK_MS
19
+ /**
20
+ * 6.52.3: how long the owner must be quiet before an OLDER boot may take the ring back.
21
+ * On 2026-09-20 a hidden copy from the night before took the ring after the live copy
22
+ * went quiet (a locked phone throttles its timers) and answered a hold-to-talk 27 minutes
23
+ * later with a stale session. 45 s is a normal quiet spell for a locked phone; a dead
24
+ * copy stays dead, so waiting ten minutes costs nothing.
25
+ */
26
+ export const CLIENT_INSTANCE_RECLAIM_MS = 10 * 60_000
27
+ /**
28
+ * 6.52.3: an older boot may take the ring back only when ITS OWN previous claim is this
29
+ * recent. Both copies sleep when the phone does; a zombie that woke up alongside the live
30
+ * copy has a gap of its own, and the live copy's refresh lands within a tick.
31
+ */
32
+ export const CLIENT_INSTANCE_AWAKE_MS = CLIENT_INSTANCE_LIVE_MS
19
33
  /**
20
34
  * 6.50.4: a meeting chunk inside this window means that copy is recording. Five chunk
21
35
  * intervals (~6 s each), so a stretch of silence or a slow upload does not end the hold.
@@ -126,6 +140,8 @@ export function arbitrateClientInstance(
126
140
  claim: ClientInstanceClaim,
127
141
  now: number,
128
142
  evidence: CaptureEvidence = NO_CAPTURE,
143
+ /** When this claimant last claimed (any verdict), or undefined: never seen. */
144
+ claimantLastSeenAt?: number,
129
145
  ): { owner: ClientInstanceOwner; verdict: ClientInstanceVerdict; took: boolean } {
130
146
  const fresh: ClientInstanceOwner = { ...claim, seenAt: now }
131
147
  if (!owner) return { owner: fresh, verdict: 'owner', took: true }
@@ -142,6 +158,11 @@ export function arbitrateClientInstance(
142
158
  // The ring moves on the newer copy's first claim after the meeting stops.
143
159
  if (ownerIsRecording(owner, evidence, now)) return { owner, verdict: 'yield', took: false }
144
160
  if (isNewer(claim, owner)) return { owner: fresh, verdict: 'owner', took: true }
145
- if (now - owner.seenAt > CLIENT_INSTANCE_LIVE_MS) return { owner: fresh, verdict: 'owner', took: true }
161
+ // 6.52.3: an older boot takes a quiet ring back only after CLIENT_INSTANCE_RECLAIM_MS,
162
+ // and only while it has itself been claiming (awake), never on the first tick after a
163
+ // gap of its own. A dead newer copy is replaced when the user reopens COS (a newer boot
164
+ // still takes at once); this rule is for the copy that is merely quiet.
165
+ const claimantAwake = claimantLastSeenAt !== undefined && now - claimantLastSeenAt <= CLIENT_INSTANCE_AWAKE_MS
166
+ if (now - owner.seenAt > CLIENT_INSTANCE_RECLAIM_MS && claimantAwake) return { owner: fresh, verdict: 'owner', took: true }
146
167
  return { owner, verdict: 'yield', took: false }
147
168
  }
@@ -37,7 +37,7 @@ export type SessionStreamDraft =
37
37
  // ADDITIVE TO A CLOSED SET, AND SAFE BY CONSTRUCTION: the client validates `kind`
38
38
  // against its own table and ignores anything it does not know, so a build that
39
39
  // predates this renders exactly as it did before rather than breaking.
40
- | { kind: 'prompt'; text: string }
40
+ | { kind: 'prompt'; text: string; turn_started_at?: string }
41
41
  | { kind: 'prose'; text: string }
42
42
  // 6.49.0: `tool_outcome` rides on a status draft, never on a new kind. A shipped
43
43
  // client drops an unknown kind BEFORE seq accounting and paints a gap row for it
@@ -173,6 +173,35 @@ function asRecord(value: unknown): Record<string, unknown> | null {
173
173
  : null
174
174
  }
175
175
 
176
+ /** ISO time the current turn actually started, from the transcript record. */
177
+ export function recordTimestampIso(record: Record<string, unknown>): string | undefined {
178
+ const nested = asRecord(record.payload)
179
+ const item = asRecord(record.item)
180
+ const candidates = [record.timestamp, record.ts, nested?.timestamp, nested?.ts, item?.timestamp]
181
+ for (const c of candidates) {
182
+ if (typeof c === 'string' && c.trim()) {
183
+ const t = Date.parse(c)
184
+ if (Number.isFinite(t) && t > 0) return new Date(t).toISOString()
185
+ }
186
+ if (typeof c === 'number' && Number.isFinite(c) && c > 0) {
187
+ const ms = c < 1e12 ? c * 1000 : c
188
+ if (Number.isFinite(ms) && ms > 0) return new Date(ms).toISOString()
189
+ }
190
+ }
191
+ return undefined
192
+ }
193
+
194
+ function stampPromptTurnStart(drafts: SessionStreamDraft[], record: Record<string, unknown>): SessionStreamDraft[] {
195
+ const iso = recordTimestampIso(record)
196
+ if (!iso) return drafts
197
+ let stamped = false
198
+ return drafts.map((draft) => {
199
+ if (draft.kind !== 'prompt' || stamped) return draft
200
+ stamped = true
201
+ return { ...draft, turn_started_at: iso }
202
+ })
203
+ }
204
+
176
205
  function countLines(value: unknown): number {
177
206
  if (typeof value !== 'string' || value.length === 0) return 0
178
207
  return value.split('\n').length
@@ -1233,9 +1262,8 @@ export function draftsFromRecord(
1233
1262
  const obj = asRecord(record)
1234
1263
  if (!obj) return []
1235
1264
  try {
1236
- if (provider === 'codex') return draftsFromCodexRecord(obj)
1237
- // Cursor shares Claude's content-block shape, keyed off `role` instead of `type`.
1238
- return draftsFromClaudeRecord(obj)
1265
+ const drafts = provider === 'codex' ? draftsFromCodexRecord(obj) : draftsFromClaudeRecord(obj)
1266
+ return stampPromptTurnStart(drafts, obj)
1239
1267
  } catch {
1240
1268
  // A malformed record costs one line of the trail. It must never cost the stream.
1241
1269
  return []
@@ -27,6 +27,8 @@ import {
27
27
 
28
28
  /** Owners kept at once; the least recently seen device is dropped past this. */
29
29
  export const CLIENT_INSTANCE_MAX_DEVICES = 16
30
+ /** Copies whose last claim time is remembered (6.52.3); the least recent is dropped past this. */
31
+ export const CLIENT_INSTANCE_MAX_SEEN = 64
30
32
 
31
33
  export interface ClientInstanceRouterDeps {
32
34
  now?: () => number
@@ -49,6 +51,13 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
49
51
  const router = Router()
50
52
  const owners = new Map<string, ClientInstanceOwner>()
51
53
  const chunks = new ClientChunkLedger()
54
+ // 6.52.3: when each copy last claimed, so an older boot proves it was awake before it
55
+ // may take a quiet ring back. Bounded like the chunk ledger.
56
+ const lastSeen = new Map<string, number>()
57
+ const noteSeen = (id: string, at: number) => {
58
+ lastSeen.delete(id); lastSeen.set(id, at)
59
+ while (lastSeen.size > CLIENT_INSTANCE_MAX_SEEN) lastSeen.delete(lastSeen.keys().next().value as string)
60
+ }
52
61
  router.use((req, _res, next) => {
53
62
  if (isMeetingChunkPost(req.method, req.path)) chunks.note(deviceKey(req.socket?.remoteAddress ?? req.ip), parseInstanceTag(req.query?.clientInstance), now())
54
63
  next()
@@ -61,7 +70,9 @@ export function createClientInstanceRouter(deps: ClientInstanceRouterDeps | (()
61
70
  const previous = owners.get(device) ?? null
62
71
  const evidence = previous ? chunks.evidence(device, previous.id, claim.id, at) : NO_CAPTURE
63
72
  const captureLive = previous ? ownerIsRecording(previous, evidence, at) : false
64
- const result = arbitrateClientInstance(previous, claim, at, evidence)
73
+ const claimantLastSeenAt = lastSeen.get(claim.id)
74
+ noteSeen(claim.id, at)
75
+ const result = arbitrateClientInstance(previous, claim, at, evidence, claimantLastSeenAt)
65
76
  if (req.body?.check === true) {
66
77
  const shown = previous ?? result.owner
67
78
  return void res.json({ verdict: result.verdict, owner: { id: shown.id, bootAt: shown.bootAt, version: shown.version, seenAt: shown.seenAt }, check: true, captureLive })
@@ -588,6 +588,10 @@ healthRouter.get('/models', async (req, res) => {
588
588
  // `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
589
589
  // quiet for `liveWindowMs` hands every held request back to the Mac.
590
590
  sessionQuestions: sessionQuestionsCapability(),
591
+ // 6.52.1: Sessions prompt events carry additive `turn_started_at` (ISO), the
592
+ // real user-record time. Transport `at` stays the seed/live stamp. Absent on
593
+ // 6.52.0, the client falls back to a local pin.
594
+ runningTurnMeta: { protocolVersion: 1 },
591
595
  transcription: {
592
596
  ...transcription,
593
597
  live: transcriptionLive,