@gotcos/glasses-server 6.58.2 → 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.
package/server/index.ts CHANGED
@@ -24,13 +24,15 @@ import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
24
24
  import { createAttachedTurnStream } from './lib/session-stream-producer.js'
25
25
  import { claudeSessionsRouter } from './routes/claude-sessions.js'
26
26
  import { createSessionHooksRouter } from './routes/session-hooks.js'
27
- import { cachedHookStatus, claudeDeskRunning, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
27
+ import { cachedHookStatus, claudeDeskRunning, onSessionRowEnded, deskIdleSeconds, deskTurnEndedAt, haltHandedOffTurn, registerDrainKickStats, registryIdleAfterStop, sessionHooksEnabled, sessionSignalStore, signalFor, startSessionHooksRuntime } from './lib/session-hooks-runtime.js'
28
28
  import { writeHaltMarker } from './lib/session-halt.js'
29
29
  import { appendSessionCancelLedger, cancelHoldUntil as cancelHoldUntilFor, noteThreadCancelled, threadCancel } from './lib/session-cancel.js'
30
30
  import { makeQueueTurnEvidence } from './lib/queue-turn-evidence.js'
31
31
  import {
32
32
  PermissionBroker,
33
33
  brokerSignalSink,
34
+ permissionBrokerAwayHoldMs,
35
+ permissionBrokerAwayRecentClientMs,
34
36
  permissionBrokerMode,
35
37
  permissionBrokerTimeoutMs,
36
38
  readHidIdleSeconds,
@@ -38,7 +40,7 @@ import {
38
40
  wirePermissionBrokerToSignals,
39
41
  } from './lib/permission-broker.js'
40
42
  import { createPermissionBrokerHookRouter, createSessionQuestionsRouter } from './routes/permission-broker.js'
41
- import { lastQuestionsPollAt } from './lib/client-liveness.js'
43
+ import { lastHoldQuestionsPollAt, lastLegacyQuestionsPollAt, lastQuestionsPollAt, presenceAgeMs } from './lib/client-liveness.js'
42
44
  import {
43
45
  createAgentSessionBindingsRouter,
44
46
  TargetGuard,
@@ -125,9 +127,13 @@ import { createQueryJobsRouter } from './routes/query-jobs.js'
125
127
  import { createWorkReviewsRouter } from './routes/work-reviews.js'
126
128
  import { createWorkBoardRouter } from './routes/work-board.js'
127
129
  import { createWorkIntakeRouter } from './routes/work-intake.js'
130
+ import { createWorkHandoffRequestsRouter } from './routes/work-handoff-requests.js'
128
131
  import { createJevRouter } from './routes/jev.js'
129
132
  import { dataPath } from './lib/data-dir.js'
130
133
  import { WorkIntakeStore, createOptionalWorkIntakeStore } from './lib/work-intake-store.js'
134
+ import { WorkHandoffRequestStore, createOptionalWorkHandoffRequestStore, handoffRequestCapabilities } from './lib/work-handoff-requests.js'
135
+ import { createWorkBoardReader } from './lib/work-board-reader.js'
136
+ import { listBoard } from './lib/task-store.js'
131
137
  import { createDefaultWorkReviewRuntime } from './lib/work-review-backend.js'
132
138
  import { createMorningBriefRouter } from './routes/morning-brief.js'
133
139
  import { getMorningBriefScheduler, startMorningBriefScheduler, stopMorningBriefScheduler } from './lib/morning-brief-runtime.js'
@@ -237,6 +243,13 @@ app.use(cors({
237
243
  // (lib/permission-broker.ts). An app without the question UI never polls, so for it every
238
244
  // request is `{}`. `COS_PERMISSION_BROKER=0` is the switch, read per request, and every
239
245
  // request outside the gate is answered `{}` at once.
246
+ // 6.60.0: the away hold. While the desk is idle, and only when glasses or a phone that send
247
+ // `hold=1` reported wearer presence within `COS_PERMISSION_BROKER_AWAY_RECENT_CLIENT_S`
248
+ // (default 1800; in memory, the larger of the monotonic and wall-clock age), a request is held
249
+ // up to `COS_PERMISSION_BROKER_AWAY_HOLD_S` (default 600, `0` is the 6.59.0 broker) whether or
250
+ // not a client is polling right now, never past the PermissionRequest timeout the Claude
251
+ // settings file carries (read through the 5 s hook-status cache) and the hook's own stamp.
252
+ // Older clients (no `hold=1`) keep the 6.59.0 liveness and never enable it.
240
253
  const permissionBroker = new PermissionBroker({
241
254
  now: () => Date.now(),
242
255
  mode: () => permissionBrokerMode(process.env, sessionHooksEnabled()),
@@ -245,10 +258,22 @@ const permissionBroker = new PermissionBroker({
245
258
  readDeskIdleSeconds: readHidIdleSeconds,
246
259
  deskIdleSeconds,
247
260
  timeoutMs: () => permissionBrokerTimeoutMs(process.env),
261
+ awayHoldMs: () => permissionBrokerAwayHoldMs(process.env),
262
+ installedHookTimeoutS: () => cachedHookStatus().permissionHookTimeoutS,
263
+ lastLegacyPollAt: lastLegacyQuestionsPollAt,
264
+ lastHoldPollAt: lastHoldQuestionsPollAt,
265
+ presenceAgeMs: () => presenceAgeMs(),
266
+ awayRecentClientMs: () => permissionBrokerAwayRecentClientMs(process.env),
267
+ stillWaitingAtMac: (sessionId, fingerprint) => {
268
+ const signal = sessionSignalStore.get(sessionId)
269
+ return !!signal && !signal.ended && signal.waiting?.fingerprint === fingerprint
270
+ },
248
271
  signals: brokerSignalSink(sessionSignalStore),
249
272
  })
250
273
  registerPermissionBroker(permissionBroker)
251
274
  wirePermissionBrokerToSignals(permissionBroker, sessionSignalStore)
275
+ // 6.60.0 (QA round 1, N2): a row that reads ended clears "waiting at the Mac" at once.
276
+ onSessionRowEnded(sessionId => { permissionBroker.sessionRowEnded(sessionId) })
252
277
 
253
278
  // THE TWO HOOK-TOKEN DOORS, deliberately BEFORE the /api gate and the global body parser.
254
279
  // Each checks the per-install hook token (never the pairing token) in constant time before
@@ -625,7 +650,13 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
625
650
  }))
626
651
  const workReviewRuntime = createDefaultWorkReviewRuntime()
627
652
  app.use('/api', createWorkReviewsRouter(workReviewRuntime))
628
- app.use('/api', createWorkBoardRouter())
653
+ // Work handoff requests (6.59.0): the glasses ask for Work to start and COS Control claims them. Optional: a bad
654
+ // journal answers 503 and `capabilities.requests: 0`, never blocks boot. The server runs no provider for a request.
655
+ const workHandoffRequestStore = createOptionalWorkHandoffRequestStore(() => new WorkHandoffRequestStore(dataPath('work-handoff-requests')))
656
+ // One bounded board read (5 s) shared by the glasses' Work glances and the request inbox; GET /api/work-board primes it.
657
+ const workBoardReader = createWorkBoardReader(() => listBoard())
658
+ app.use('/api', createWorkBoardRouter({ readBoard: workBoardReader, requests: () => handoffRequestCapabilities(workHandoffRequestStore, Date.now()) }))
659
+ app.use('/api', createWorkHandoffRequestsRouter({ store: workHandoffRequestStore, readBoard: workBoardReader }))
629
660
  // Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
630
661
  const workIntakeStore = createOptionalWorkIntakeStore(() => new WorkIntakeStore(dataPath('work-intake')))
631
662
  app.use('/api', createWorkIntakeRouter({ store: workIntakeStore }))
@@ -948,6 +979,7 @@ async function gracefulShutdown(): Promise<void> {
948
979
  stopMorningBriefScheduler()
949
980
  await workReviewRuntime?.close()
950
981
  workIntakeStore?.close()
982
+ workHandoffRequestStore?.close()
951
983
  stopMeetingImportScheduler()
952
984
  stopMeetingMergeScheduler()
953
985
  sessionHooksRuntime.stop()
@@ -36,8 +36,24 @@ export interface HookSubscription {
36
36
  matcher?: string
37
37
  async: boolean
38
38
  timeout: number
39
+ /**
40
+ * 6.60.0: the command passes the block's own `timeout` to the script as its second
41
+ * argument. The script stops waiting 5 s before it and stamps it on the request it posts
42
+ * (`hookWaitS`), so the broker knows the wait of THIS invocation. The command and the
43
+ * timeout sit in one block, so whether or not a running Claude session re-reads its hook
44
+ * settings, the stamp it sends and the timeout it enforces come from the same block.
45
+ */
46
+ passTimeout?: boolean
39
47
  }
40
48
 
49
+ /**
50
+ * 6.60.0: Claude Code's wait on the PermissionRequest hook: the away hold (default 600 s)
51
+ * plus slack. The script's curl stops at 625 and the broker answers by 620 at the latest.
52
+ */
53
+ export const PERMISSION_HOOK_TIMEOUT_S = 630
54
+ /** What every install before 6.60.0 carries for PermissionRequest, with no wait argument. */
55
+ export const LEGACY_PERMISSION_HOOK_TIMEOUT_S = 130
56
+
41
57
  /**
42
58
  * Every event the reducer understands, with the timing each one needs. SessionStart,
43
59
  * UserPromptSubmit, Stop and SessionEnd are synchronous: measured 2026-09-15, an async
@@ -55,6 +71,10 @@ export interface HookSubscription {
55
71
  * because the merge keeps exactly one of ours per event and status requires it: the script
56
72
  * spools only AskUserQuestion and ExitPlanMode, which is what the old async matcher block
57
73
  * did. Changing this row makes every existing install read `drift` until Install hooks.
74
+ *
75
+ * 6.60.0: PermissionRequest waits 630 s (was 130) and passes that number to the script, for
76
+ * the away hold. An install that differs ONLY by still carrying the earlier block (130 s, no
77
+ * argument) reads `drift` with `priorWaitOnly`, and stays halt-ready (`hookHaltReady`).
58
78
  */
59
79
  export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
60
80
  { event: 'SessionStart', async: false, timeout: 5 },
@@ -62,7 +82,7 @@ export const HOOK_SUBSCRIPTIONS: readonly HookSubscription[] = [
62
82
  { event: 'UserPromptSubmit', async: false, timeout: 5 },
63
83
  { event: 'Stop', async: false, timeout: 5 },
64
84
  { event: 'StopFailure', async: true, timeout: 10 },
65
- { event: 'PermissionRequest', async: false, timeout: 130 },
85
+ { event: 'PermissionRequest', async: false, timeout: PERMISSION_HOOK_TIMEOUT_S, passTimeout: true },
66
86
  { event: 'PermissionDenied', async: true, timeout: 10 },
67
87
  { event: 'PreToolUse', async: false, timeout: 5 },
68
88
  { event: 'PostToolUse', async: true, timeout: 10 },
@@ -117,9 +137,13 @@ export function shellQuote(value: string): string {
117
137
  /**
118
138
  * The installed command carries the two paths the script needs as environment, so the
119
139
  * server and the script can never disagree about where the spool or the token is.
140
+ * 6.60.0: `waitS`, for a subscription with `passTimeout`, follows the event name as the
141
+ * script's second argument (an argument, never an environment variable: a variable could
142
+ * be inherited from the shell that started Claude, an argument only comes from this block).
120
143
  */
121
- export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths()): string {
122
- return `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
144
+ export function hookCommand(scriptPath: string, event: string, paths: HookPaths = currentHookPaths(), waitS?: number): string {
145
+ const base = `COS_GLASSES_HOME=${shellQuote(paths.home)} COS_HOOK_SPOOL=${shellQuote(paths.spoolDir)} ${shellQuote(scriptPath)} ${event}`
146
+ return waitS === undefined ? base : `${base} ${Math.trunc(waitS)}`
123
147
  }
124
148
 
125
149
  export interface HookPaths {
@@ -133,20 +157,57 @@ export function currentHookPaths(): HookPaths {
133
157
 
134
158
  type HookBlock = { matcher?: unknown; hooks?: unknown }
135
159
 
160
+ function hookIsOurs(h: unknown): boolean {
161
+ return !!h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
162
+ && ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' '))
163
+ }
164
+
136
165
  function blockIsOurs(block: unknown): boolean {
137
166
  if (!block || typeof block !== 'object') return false
138
167
  const hooks = (block as HookBlock).hooks
139
168
  if (!Array.isArray(hooks)) return false
140
- return hooks.some(h => h && typeof h === 'object' && typeof (h as { command?: unknown }).command === 'string'
141
- && ((h as { command: string }).command.includes(HOOK_SCRIPT_MARKER) || (h as { command: string }).command.includes(HOOK_SCRIPT_NAME + ' ')))
169
+ return hooks.some(hookIsOurs)
142
170
  }
143
171
 
144
172
  function canonicalBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> {
145
- const hook: Record<string, unknown> = { type: 'command', command: hookCommand(scriptPath, sub.event, paths), timeout: sub.timeout }
173
+ const command = hookCommand(scriptPath, sub.event, paths, sub.passTimeout ? sub.timeout : undefined)
174
+ const hook: Record<string, unknown> = { type: 'command', command, timeout: sub.timeout }
146
175
  if (sub.async) hook.async = true
147
176
  return sub.matcher ? { matcher: sub.matcher, hooks: [hook] } : { hooks: [hook] }
148
177
  }
149
178
 
179
+ /**
180
+ * 6.60.0: the block an install before 6.60.0 wrote for this event, when this release changed
181
+ * it only by the wait: PermissionRequest at 130 s with no wait argument. Null for any other.
182
+ */
183
+ function priorWaitBlock(scriptPath: string, sub: HookSubscription, paths: HookPaths): Record<string, unknown> | null {
184
+ if (sub.event !== 'PermissionRequest') return null
185
+ return canonicalBlock(scriptPath, { event: sub.event, async: sub.async, timeout: LEGACY_PERMISSION_HOOK_TIMEOUT_S }, paths)
186
+ }
187
+
188
+ /**
189
+ * 6.60.0: how long Claude Code waits on OUR PermissionRequest hook, read from a settings
190
+ * object: the smallest `timeout` among our entries (Claude runs each, so the first to give up
191
+ * bounds the wait). Null when none of ours is there, or one of ours has no usable number:
192
+ * the broker then keeps the old safe clamp (`lib/permission-broker.ts` `holdCeilingS`).
193
+ */
194
+ export function permissionHookTimeoutS(settings: unknown): number | null {
195
+ const hooks = settings && typeof settings === 'object' ? (settings as Record<string, unknown>).hooks : null
196
+ const blocks = hooks && typeof hooks === 'object' ? (hooks as Record<string, unknown>).PermissionRequest : null
197
+ if (!Array.isArray(blocks)) return null
198
+ let least: number | null = null
199
+ for (const block of blocks) {
200
+ if (!blockIsOurs(block)) continue
201
+ for (const h of (block as HookBlock).hooks as unknown[]) {
202
+ if (!hookIsOurs(h)) continue
203
+ const timeout = (h as { timeout?: unknown }).timeout
204
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) return null
205
+ least = least === null ? timeout : Math.min(least, timeout)
206
+ }
207
+ }
208
+ return least
209
+ }
210
+
150
211
  export type MergeResult =
151
212
  | { ok: true; settings: Record<string, unknown>; changed: boolean }
152
213
  /** The file holds a `hooks` shape this merge would have to destroy to proceed. */
@@ -190,22 +251,29 @@ export function stripHookSettings(current: unknown): MergeResult {
190
251
  return { ok: true, settings, changed: JSON.stringify(settings) !== before }
191
252
  }
192
253
 
193
- /** Which of our events a settings object carries, by exact canonical command. */
194
- export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[] } {
254
+ /**
255
+ * Which of our events a settings object carries, by exact canonical command. `priorWait`
256
+ * (6.60.0) names the drifted events whose one block of ours is exactly the block an earlier
257
+ * release wrote, differing only by the wait (`priorWaitBlock`).
258
+ */
259
+ export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[]; priorWait: string[] } {
195
260
  const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
196
261
  const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
197
262
  const subscribed: string[] = []
198
263
  const missing: string[] = []
199
264
  const drifted: string[] = []
265
+ const priorWait: string[] = []
200
266
  for (const sub of HOOK_SUBSCRIPTIONS) {
201
267
  const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
202
268
  const ours = blocks.filter(blockIsOurs)
203
269
  if (ours.length === 0) { missing.push(sub.event); continue }
204
270
  const canonical = JSON.stringify(canonicalBlock(scriptPath, sub, paths))
205
- if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) subscribed.push(sub.event)
206
- else drifted.push(sub.event)
271
+ if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) { subscribed.push(sub.event); continue }
272
+ drifted.push(sub.event)
273
+ const prior = priorWaitBlock(scriptPath, sub, paths)
274
+ if (prior && ours.length === 1 && JSON.stringify(ours[0]) === JSON.stringify(prior)) priorWait.push(sub.event)
207
275
  }
208
- return { subscribed, missing, drifted }
276
+ return { subscribed, missing, drifted, priorWait }
209
277
  }
210
278
 
211
279
  export type HookInstallState =
@@ -231,6 +299,16 @@ export interface HookStatus {
231
299
  subscribed: string[]
232
300
  missing: string[]
233
301
  drifted: string[]
302
+ /** 6.60.0: drifted events whose block is exactly an earlier release's, but for the wait. */
303
+ priorWait: string[]
304
+ /**
305
+ * 6.60.0: `drift`, and the ONLY difference is that the settings still carry the earlier
306
+ * PermissionRequest block (130 s, no wait argument): an install from before 6.60.0 that
307
+ * has not run Install hooks since. Questions are then held at most 120 s, as before.
308
+ */
309
+ priorWaitOnly: boolean
310
+ /** 6.60.0: our PermissionRequest hook's `timeout` in the settings file, or null (`permissionHookTimeoutS`). */
311
+ permissionHookTimeoutS: number | null
234
312
  tokenPresent: boolean
235
313
  }
236
314
 
@@ -272,16 +350,19 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
272
350
  const tokenPresent = existsSync(hookTokenPath())
273
351
  const read = readSettings(settingsPath)
274
352
  if (!read.ok) {
275
- return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], tokenPresent }
353
+ return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], priorWait: [], priorWaitOnly: false, permissionHookTimeoutS: null, tokenPresent }
276
354
  }
277
355
  const events = subscribedEvents(read.settings, scriptPath, hookPaths)
356
+ const permissionWaitS = permissionHookTimeoutS(read.settings)
278
357
  let state: HookInstallState
279
358
  if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
280
359
  else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
281
360
  else if (events.missing.length > 0 || events.drifted.length > 0) state = 'drift'
282
361
  else if (!scriptSha || (packageScriptSha && scriptSha !== packageScriptSha)) state = 'script_outdated'
283
362
  else state = 'installed'
284
- return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, tokenPresent }
363
+ const priorWaitOnly = state === 'drift' && events.missing.length === 0 && events.drifted.length > 0
364
+ && events.drifted.every(event => events.priorWait.includes(event))
365
+ return { state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
285
366
  }
286
367
 
287
368
  /**
@@ -295,22 +376,35 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
295
376
  * - c0b41bf8...: the 6.53.3 script as first built, before its /qa round made every event
296
377
  * drain stdin. Never published, but installable from a local build of that commit
297
378
  * (7433b7f), and it carries the same halt check.
379
+ * - df54677f...: the script 6.53.3 through 6.59.0 shipped. 6.60.0 changed it only for the
380
+ * away hold (the PermissionRequest wait); its halt check is the same.
298
381
  * Each is pinned against its bytes in `server/lib/__fixtures__/`.
299
382
  */
300
383
  export const HALT_CAPABLE_PRIOR_SCRIPT_SHAS: readonly string[] = [
301
384
  '1158bb06297550128f01fc47caa68806a5287d6da2d05b0d1c812e8ae20764e7',
302
385
  'c0b41bf8581cfea498e1e1ac5220fe4e148bd97448d13b60a88879ee5992cc51',
386
+ 'df54677f79908893509ee87608cfe3f4a53ced03f88f337f16a63741d6b3d547',
303
387
  ]
304
388
 
305
389
  /**
306
390
  * 6.53.3: can the hooks on this Mac stop a desk Claude run now? `installed`, or the only
307
391
  * difference is a halt-capable earlier script (above). What the cancel route's
308
392
  * `hooksReady` and `/api/health` `features.sessionCancel.deskClaude` read.
393
+ *
394
+ * 6.60.0: or `drift` with `priorWaitOnly` (the settings still carry the earlier
395
+ * PermissionRequest wait, every other block is current) on this package's script or a
396
+ * halt-capable earlier one. The wait changes how long a question is held, not the halt
397
+ * check, so updating the server never turns cancel from the lens off. Any other drift
398
+ * still is not halt-ready.
309
399
  */
310
- export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'>): boolean {
400
+ export function hookHaltReady(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly' | 'packageScriptSha'>>): boolean {
311
401
  if (status.installed === true) return true
312
- return status.state === 'script_outdated' && typeof status.scriptSha === 'string'
313
- && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
402
+ const priorScript = typeof status.scriptSha === 'string' && HALT_CAPABLE_PRIOR_SCRIPT_SHAS.includes(status.scriptSha)
403
+ if (status.state === 'script_outdated') return priorScript
404
+ if (status.state === 'drift' && status.priorWaitOnly === true) {
405
+ return priorScript || (typeof status.scriptSha === 'string' && status.scriptSha === status.packageScriptSha)
406
+ }
407
+ return false
314
408
  }
315
409
 
316
410
  const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install'
@@ -319,8 +413,17 @@ const INSTALL_COMMAND = 'npx --yes @gotcos/glasses-server@latest --hooks install
319
413
  * 6.53.3: one plain sentence for `--hooks status` saying what the state means and what fixes
320
414
  * it. Null when installed. Carries no path: the status JSON beside it already names them.
321
415
  */
322
- export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'>): string | null {
416
+ export function hookStatusAdvice(status: Pick<HookStatus, 'installed' | 'state' | 'scriptSha' | 'packageScriptSha'> & Partial<Pick<HookStatus, 'priorWaitOnly'>>): string | null {
323
417
  const short = (sha: string | null): string => (sha ? sha.slice(0, 12) : 'none')
418
+ // 6.60.0: the one drift an update itself causes. Say what it costs and that nothing broke.
419
+ if (status.state === 'drift' && status.priorWaitOnly === true) {
420
+ return 'The Claude settings file still carries the earlier permission hook wait (130 s), so a question that starts while you are away '
421
+ + 'is held for the glasses for at most 2 minutes instead of 10. '
422
+ + `Run \`${INSTALL_COMMAND}\`, or press Install hooks in COS Control. `
423
+ + (hookHaltReady(status)
424
+ ? 'Cancel from the lens still stops desk runs meanwhile.'
425
+ : 'Until then a desk run cannot be cancelled from the lens.')
426
+ }
324
427
  switch (status.state) {
325
428
  case 'installed':
326
429
  return null
@@ -23,28 +23,116 @@
23
23
  //
24
24
  // In memory: a restart forgets it, and the next poll restores it. Until then nothing is
25
25
  // held, which is the native dialog, the safe side.
26
+ //
27
+ // 6.60.0 (QA round 1, the contract shared with COS Glasses 6.9.563): the away hold needs
28
+ // more than a poll. A poll QUALIFIES only with `hold=1` (this client shows held questions;
29
+ // 6.9.563 and later send it) AND a finite `presenceAgeMs` within [0, 24 h]: the ms since
30
+ // the latest real wearer evidence (glasses: a ring or temple input, or a device status with
31
+ // wearing true; phone: a foreground touch). A connected lens nobody wears sends no
32
+ // `presenceAgeMs`, and an older client sends no `hold=1`, so neither can make the Mac hold a
33
+ // question for 10 minutes.
34
+ //
35
+ // Kept apart, all in memory (a restart forgets them, which fails toward 6.59.0):
36
+ // - the newest counting poll of any kind (`lastQuestionsPollAt`, for /api/health);
37
+ // - the newest poll WITHOUT `hold=1` (`lastLegacyQuestionsPollAt`): the 6.59.0 liveness,
38
+ // unchanged for every older client;
39
+ // - the newest poll WITH `hold=1` (`lastHoldQuestionsPollAt`);
40
+ // - the newest qualifying presence, on BOTH clocks, each forward-only: monotonic
41
+ // (`performance.now()`, immune to the wall clock stepping) and wall. Its age is the
42
+ // larger of the two (`presenceAgeMs`): on macOS the monotonic clock does not advance
43
+ // while the Mac sleeps, so on its own it would read a pre-sleep presence as younger
44
+ // than it is; the wall clock does advance. A wall clock stepped forward reads older
45
+ // (no hold), never younger.
46
+
47
+ /** 6.60.0: the most a client may report as `presenceAgeMs`. Anything else is no presence. */
48
+ export const PRESENCE_AGE_MAX_MS = 86_400_000
26
49
 
27
50
  let lastPollAt: number | null = null
51
+ let lastLegacyPollAt: number | null = null
52
+ let lastHoldPollAt: number | null = null
53
+ let lastPresenceMono: number | null = null
54
+ let lastPresenceWall: number | null = null
55
+
56
+ /** 6.60.0: the monotonic clock. Milliseconds since this process started; paused while the Mac sleeps. */
57
+ export function monotonicNowMs(): number {
58
+ return performance.now()
59
+ }
28
60
 
29
61
  /** A stored stamp this far ahead of a new poll means the clock stepped back. */
30
62
  const CLOCK_STEP_BACK_MS = 5_000
31
63
 
64
+ /** Newest-wins for a poll stamp, following a clock that stepped back (see below). */
65
+ function newerStamp(stored: number | null, at: number): number {
66
+ return stored === null || at > stored || stored - at > CLOCK_STEP_BACK_MS ? at : stored
67
+ }
68
+
69
+ /** 6.60.0: what a counting poll said about the client, read from its query. */
70
+ export interface QuestionsPollFacts {
71
+ /** `hold=1`: this client can show held questions. */
72
+ hold?: boolean
73
+ /** ms since the latest real wearer evidence; null or absent when there is none since boot. */
74
+ presenceAgeMs?: number | null
75
+ }
76
+
77
+ /** 6.60.0: a `presenceAgeMs` the contract accepts: a finite whole number within [0, 24 h]. */
78
+ export function validPresenceAgeMs(value: unknown): value is number {
79
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= PRESENCE_AGE_MAX_MS
80
+ }
81
+
32
82
  /**
33
83
  * A counting questions poll answered at `at` (server clock). Never moves backwards for a
34
84
  * slower request answered later, but a clock that stepped back is followed: a stamp left in
35
85
  * the future would read stale to the broker, and ignoring every poll until the clock caught
36
- * up would leave no live client for that long.
86
+ * up would leave no live client for that long. 6.60.0: `poll` says whether it was a `hold=1`
87
+ * poll and carried qualifying presence; `mono` is the monotonic time it was answered.
37
88
  */
38
- export function noteQuestionsPoll(at: number): void {
89
+ export function noteQuestionsPoll(at: number, poll: QuestionsPollFacts = {}, mono: number = monotonicNowMs()): void {
39
90
  if (!Number.isFinite(at)) return
40
- if (lastPollAt === null || at > lastPollAt || lastPollAt - at > CLOCK_STEP_BACK_MS) lastPollAt = at
91
+ lastPollAt = newerStamp(lastPollAt, at)
92
+ if (poll.hold !== true) {
93
+ lastLegacyPollAt = newerStamp(lastLegacyPollAt, at)
94
+ return
95
+ }
96
+ lastHoldPollAt = newerStamp(lastHoldPollAt, at)
97
+ if (!validPresenceAgeMs(poll.presenceAgeMs) || !Number.isFinite(mono)) return
98
+ // Forward-only on each clock: a slower poll carrying older evidence never moves it back.
99
+ const presenceMono = mono - poll.presenceAgeMs
100
+ const presenceWall = at - poll.presenceAgeMs
101
+ if (lastPresenceMono === null || presenceMono > lastPresenceMono) lastPresenceMono = presenceMono
102
+ if (lastPresenceWall === null || presenceWall > lastPresenceWall) lastPresenceWall = presenceWall
103
+ }
104
+
105
+ /**
106
+ * 6.60.0: how long ago the newest qualifying wearer presence was, or null when no
107
+ * qualifying poll has arrived since this process started. The larger of the monotonic age
108
+ * and the wall-clock age, so a Mac that slept never makes it read younger.
109
+ */
110
+ export function presenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
111
+ if (lastPresenceMono === null || !Number.isFinite(mono)) return null
112
+ const monoAge = Math.max(0, mono - lastPresenceMono)
113
+ const wallAge = lastPresenceWall !== null && Number.isFinite(wall) ? wall - lastPresenceWall : 0
114
+ return Math.max(monoAge, wallAge)
41
115
  }
42
116
 
43
- /** When the newest authenticated questions poll arrived, or null when none has since boot. */
117
+ /** When the newest authenticated questions poll of any kind arrived, or null since boot (/api/health). */
44
118
  export function lastQuestionsPollAt(): number | null {
45
119
  return lastPollAt
46
120
  }
47
121
 
122
+ /** 6.60.0: the newest poll WITHOUT `hold=1`: the 6.59.0 liveness, for every older client. */
123
+ export function lastLegacyQuestionsPollAt(): number | null {
124
+ return lastLegacyPollAt
125
+ }
126
+
127
+ /** 6.60.0: the newest poll WITH `hold=1`. */
128
+ export function lastHoldQuestionsPollAt(): number | null {
129
+ return lastHoldPollAt
130
+ }
131
+
48
132
  export function __resetClientLivenessForTests(): void {
49
133
  lastPollAt = null
134
+ lastLegacyPollAt = null
135
+ lastHoldPollAt = null
136
+ lastPresenceMono = null
137
+ lastPresenceWall = null
50
138
  }