@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/CHANGELOG.md +57 -0
- package/README.md +79 -7
- package/bin/hooks/cos-session-hook +24 -4
- package/docs/work-handoff-requests.md +181 -0
- package/docs/work-intake.md +2 -0
- package/docs/work-task-runtime.md +16 -6
- package/managed-runtime-contract.json +2 -0
- package/package.json +5 -2
- package/server/index.ts +35 -3
- 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/query-job-types.ts +11 -2
- package/server/lib/session-hooks-runtime.ts +23 -0
- package/server/lib/work-activity.ts +377 -39
- package/server/lib/work-board-reader.ts +63 -0
- package/server/lib/work-handoff-requests.ts +426 -0
- package/server/routes/permission-broker.ts +37 -3
- package/server/routes/work-board.ts +55 -10
- package/server/routes/work-handoff-requests.ts +159 -0
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
/**
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|