@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.
- package/CHANGELOG.md +28 -0
- package/README.md +79 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/managed-runtime-contract.json +2 -0
- package/package.json +2 -1
- package/server/index.ts +23 -2
- package/server/lib/claude-hooks-installer.ts +120 -17
- package/server/lib/client-liveness.ts +92 -4
- package/server/lib/permission-broker.ts +560 -31
- package/server/lib/session-hooks-runtime.ts +23 -0
- package/server/routes/permission-broker.ts +37 -3
|
@@ -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) => {
|