@gotcos/glasses-server 6.59.0 → 6.60.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.
@@ -335,6 +335,13 @@ export interface SessionHooksHealth {
335
335
  state: HookStatus['state']
336
336
  installed: boolean
337
337
  scriptSha: string | null
338
+ /**
339
+ * 6.60.0: our PermissionRequest hook's `timeout` in the Claude settings file (630 after
340
+ * Install hooks, 130 on an install from before 6.60.0), or null when it cannot be read.
341
+ */
342
+ permissionHookTimeoutS: number | null
343
+ /** 6.60.0: the only drift is that earlier PermissionRequest wait (`HookStatus.priorWaitOnly`). */
344
+ priorWaitOnly: boolean
338
345
  tokenPresent: boolean
339
346
  lastEventAt: string | null
340
347
  spoolBacklog: number
@@ -377,6 +384,8 @@ export function sessionHooksHealthFields(): { sessionHooks: SessionHooksHealth }
377
384
  state: status.state,
378
385
  installed: status.installed,
379
386
  scriptSha: status.scriptSha,
387
+ permissionHookTimeoutS: status.permissionHookTimeoutS,
388
+ priorWaitOnly: status.priorWaitOnly,
380
389
  tokenPresent: status.tokenPresent,
381
390
  lastEventAt: newest ? new Date(newest).toISOString() : null,
382
391
  spoolBacklog: spool?.backlog ?? 0,
@@ -398,6 +407,14 @@ export function signalFor(sessionId: string): SessionSignal | undefined {
398
407
  return sessionId.length >= 36 ? sessionSignalStore.get(sessionId) : sessionSignalStore.getByPrefix(sessionId)
399
408
  }
400
409
 
410
+ /** 6.60.0: told when a row derives `ended`, so the broker stops saying the Mac is waiting on it. */
411
+ const rowEndedListeners = new Set<(sessionId: string) => void>()
412
+
413
+ export function onSessionRowEnded(listener: (sessionId: string) => void): () => void {
414
+ rowEndedListeners.add(listener)
415
+ return () => { rowEndedListeners.delete(listener) }
416
+ }
417
+
401
418
  /**
402
419
  * Derive with the two-scan memory kept here, keyed by full id when known. Rows the
403
420
  * caller has no facts for at all get nothing, so an older payload stays byte-identical.
@@ -419,6 +436,11 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
419
436
  prevDeadSince: prev?.since ?? null,
420
437
  attachedTurn: input.attachedTurn === true,
421
438
  })
439
+ if (derived.agent_state === 'ended') {
440
+ for (const listener of rowEndedListeners) {
441
+ try { listener(key) } catch { /* a listener never breaks a row */ }
442
+ }
443
+ }
422
444
  // A reader with no registry facts (the live feed derives from the signal alone) must
423
445
  // not touch the two-scan memory the row readers keep: a derive that saw no registry
424
446
  // would reset a dead pid's count between two list requests.
@@ -431,6 +453,7 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
431
453
  }
432
454
 
433
455
  export function __resetSessionHooksForTests(): void {
456
+ rowEndedListeners.clear()
434
457
  sessionSignalStore.__resetForTests()
435
458
  deadById.clear()
436
459
  parentCache.clear()
@@ -25,6 +25,10 @@
25
25
  // so a surface that only shows a count can poll without making the Mac hold questions it
26
26
  // cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
27
27
  // without it is refused and never counted, whatever sits in front of this router.
28
+ // 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
29
+ // wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
30
+ // a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
31
+ // `awayHoldMs`, `awayRecentClientMs`, `settled`, `waitingAtMac` and `serverStartedAt`.
28
32
 
29
33
  import express, { Router, type NextFunction, type Request, type Response } from 'express'
30
34
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
@@ -37,7 +41,7 @@ import {
37
41
  type HookReplyChannel,
38
42
  type PermissionBroker,
39
43
  } from '../lib/permission-broker.js'
40
- import { noteQuestionsPoll } from '../lib/client-liveness.js'
44
+ import { noteQuestionsPoll, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
41
45
 
42
46
  export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
43
47
  /** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
@@ -133,8 +137,26 @@ export interface SessionQuestionsRouterDeps {
133
137
  /** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
134
138
  apiToken: () => string
135
139
  /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
136
- notePoll?: (at: number) => void
140
+ notePoll?: (at: number, poll: QuestionsPollFacts) => void
137
141
  now?: () => number
142
+ /** 6.60.0: when this server process started (ISO); defaults to the process's own start. */
143
+ serverStartedAt?: string
144
+ }
145
+
146
+ /** 6.60.0: this process's start, so a client can tell a restart from "nothing waiting". */
147
+ export const SERVER_STARTED_AT = new Date(Date.now() - Math.round(process.uptime() * 1000)).toISOString()
148
+
149
+ /**
150
+ * 6.60.0, the contract shared with COS Glasses 6.9.563: `hold=1` (this client shows held
151
+ * questions) and `presenceAgeMs`, a whole number of ms within [0, 24 h] since the latest real
152
+ * wearer evidence. Anything else is no presence: the poll then counts only as a 6.59.0 poll
153
+ * (without `hold=1`) or as a `hold=1` poll with no presence.
154
+ */
155
+ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPollFacts {
156
+ const hold = query.hold === '1'
157
+ const raw = typeof query.presenceAgeMs === 'string' ? query.presenceAgeMs : ''
158
+ const parsed = /^\d{1,8}$/.test(raw) ? Number(raw) : Number.NaN
159
+ return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
138
160
  }
139
161
 
140
162
  export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
@@ -152,7 +174,7 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
152
174
  }
153
175
  // Only a client that says it can answer (and shows the cards) counts as live.
154
176
  const client = typeof req.query.client === 'string' ? req.query.client : ''
155
- if (ANSWERING_CLIENTS.has(client)) notePoll(now())
177
+ if (ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
156
178
  const health = deps.broker.health()
157
179
  // Listed first: a deadline the timer has not caught yet is settled by the listing.
158
180
  const items = deps.broker.list()
@@ -165,6 +187,18 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
165
187
  pending: deps.broker.pendingCount(),
166
188
  stats: deps.broker.stats(),
167
189
  items,
190
+ // 6.60.0, additive: the away hold now (0 when off) and the recent-client window it needs,
191
+ // recently settled items (codes and times only, newest first), and how many of them the
192
+ // Mac's own dialog still waits on.
193
+ awayHoldMs: health.enabled ? deps.broker.awayHoldNowMs() : 0,
194
+ awayRecentClientMs: health.enabled && deps.broker.awayHoldNowMs() > 0 ? deps.broker.awayRecentClientWindowMs() : 0,
195
+ settled: deps.broker.settledList(),
196
+ waitingAtMac: deps.broker.waitingAtMacCount(),
197
+ serverStartedAt: deps.serverStartedAt ?? SERVER_STARTED_AT,
198
+ // 6.60.0 (Ghost Hunter W1): a waiting entry settled at or before this time may have been
199
+ // forgotten (the server started then, or dropped it while it still waited). Missing from
200
+ // `settled` and settled after it: the session moved past it.
201
+ waitingAtMacForgottenBefore: new Date(deps.broker.waitingAtMacForgottenBeforeMs()).toISOString(),
168
202
  })
169
203
  })
170
204
  router.post('/session-questions/:id/answer', (req: Request, res: Response) => {