@gotcos/glasses-server 7.0.7 → 7.0.27

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.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,152 @@
1
+ // What the 3144 signer listener serves (6.99.31, plan section 4.8, review B2 and W2). Only with
2
+ // COS_WEB_KEY_AUTHORITY on: index.ts builds this handler and the 3144 listener only then
3
+ // (lib/listener-startup.ts listenerPlan), so with the flag off none of it exists.
4
+ //
5
+ // THE ORIGIN HOLDS THE KEY AND NOTHING ELSE. The big Control Web app (transcripts, previews, the
6
+ // memories frame) stays on 3143. On 3144:
7
+ // - the page and its three scripts and one stylesheet (server/web-signer/), served ONLY from bytes
8
+ // that matched their pack-time manifest (lib/web-control-integrity.ts, a second instance over
9
+ // web-signer/). No manifest, a changed file, an extra file: a plain error page, nothing signs.
10
+ // - a short allowlist of /api routes the page calls (key status, nonce, enrollment, revoke, the
11
+ // handoff claim, and the one turn route plus its status read). Everything else is 404 here, so
12
+ // no transcript, meeting or memory route answers on this origin.
13
+ // - an API request that names any other Origin is refused, and a write must name this one, so
14
+ // no other page (3143 included) can drive this origin's API with the phone's cookie.
15
+ // - every response: a strict CSP with no inline script or style and frame-ancestors 'none',
16
+ // X-Frame-Options DENY, COOP and CORP same-origin, no referrer, no caching.
17
+
18
+ import type { IncomingMessage, ServerResponse } from 'node:http'
19
+ import type { WebControlIntegrity } from './web-control-integrity.js'
20
+ import type { IntegrityReport } from './web-key-authority.js'
21
+
22
+ export const WEB_SIGNER_CSP = [
23
+ "default-src 'none'",
24
+ "script-src 'self'",
25
+ "style-src 'self'",
26
+ "img-src 'self'",
27
+ "connect-src 'self'",
28
+ "base-uri 'none'",
29
+ "form-action 'none'",
30
+ "frame-ancestors 'none'",
31
+ "frame-src 'none'",
32
+ "object-src 'none'",
33
+ "manifest-src 'none'",
34
+ "worker-src 'none'",
35
+ "require-trusted-types-for 'script'",
36
+ "trusted-types 'none'",
37
+ ].join('; ')
38
+
39
+ export const WEB_SIGNER_HEADERS: Readonly<Record<string, string>> = Object.freeze({
40
+ 'Content-Security-Policy': WEB_SIGNER_CSP,
41
+ 'X-Frame-Options': 'DENY',
42
+ 'Cross-Origin-Opener-Policy': 'same-origin',
43
+ 'Cross-Origin-Resource-Policy': 'same-origin',
44
+ 'Referrer-Policy': 'no-referrer',
45
+ 'X-Content-Type-Options': 'nosniff',
46
+ 'Cache-Control': 'no-store',
47
+ 'Permissions-Policy': 'publickey-credentials-get=(self), publickey-credentials-create=(self), camera=(), microphone=(), geolocation=(), payment=()',
48
+ })
49
+
50
+ /** The only /api routes the signer origin answers. Method plus exact path shape. */
51
+ export const WEB_SIGNER_API_ALLOWLIST: ReadonlyArray<{ method: 'GET' | 'POST'; path: RegExp }> = Object.freeze([
52
+ { method: 'GET', path: /^\/api\/web-key\/status$/ },
53
+ { method: 'GET', path: /^\/api\/web-key\/keys$/ },
54
+ { method: 'POST', path: /^\/api\/web-key\/nonce$/ },
55
+ { method: 'POST', path: /^\/api\/web-key\/enroll\/start$/ },
56
+ { method: 'POST', path: /^\/api\/web-key\/enroll\/prove$/ },
57
+ { method: 'GET', path: /^\/api\/web-key\/enroll\/status$/ },
58
+ { method: 'POST', path: /^\/api\/web-key\/revoke$/ },
59
+ { method: 'POST', path: /^\/api\/web-key\/handoff\/claim$/ },
60
+ { method: 'POST', path: /^\/api\/agent-sessions\/bindings\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/turns$/ },
61
+ { method: 'GET', path: /^\/api\/agent-sessions\/bindings\/[A-Za-z0-9][A-Za-z0-9_-]{0,127}\/turns\/[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/ },
62
+ ])
63
+
64
+ export function signerApiAllowed(method: string | undefined, pathname: string): boolean {
65
+ const m = String(method ?? 'GET').toUpperCase()
66
+ return WEB_SIGNER_API_ALLOWLIST.some(r => r.method === m && r.path.test(pathname))
67
+ }
68
+
69
+ const TYPES: Record<string, string> = {
70
+ '.html': 'text/html; charset=utf-8',
71
+ '.js': 'text/javascript; charset=utf-8',
72
+ '.css': 'text/css; charset=utf-8',
73
+ }
74
+
75
+ export const SIGNER_PAUSED_PAGE = '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>COS approval paused</title></head><body><h1>COS approval is paused</h1><p>The approval page on this Mac does not match the one that shipped with this version, so it is not being served. Nothing was signed.</p><p>Open COS Control on the Mac to repair or reinstall the server.</p></body></html>'
76
+
77
+ /**
78
+ * The integrity the authority reads (review W1), Phase 3: the signer page holds the key, so a turn is
79
+ * vouched only while BOTH the Control Web tree and the signer tree match their manifests. Null while
80
+ * Control Web integrity is off (`integrity_off`, as before). The shape is the contract's, unchanged.
81
+ */
82
+ export function combinedIntegrityReport(webControl: Pick<WebControlIntegrity, 'state' | 'manifestSha256'> | null, signer: Pick<WebControlIntegrity, 'state'> | null): IntegrityReport | null {
83
+ if (!webControl) return null
84
+ const signerOk = signer !== null && signer.state.ok === true
85
+ return { enabled: true, lastCheckOk: webControl.state.ok === true && signerOk, checkedAt: webControl.state.checkedAt || null, manifestSha256: webControl.manifestSha256 }
86
+ }
87
+
88
+ export interface WebKeySignerDeps {
89
+ /** The integrity check over server/web-signer/. Null: nothing is served (the page is never read from disk unchecked). */
90
+ integrity: WebControlIntegrity | null
91
+ /** The main app, for the allowlisted /api routes. */
92
+ api: (req: IncomingMessage, res: ServerResponse) => void
93
+ /** `https://<MagicDNS name>:3144` (lib/web-key-origin.ts), or null when unknown. */
94
+ origin: () => string | null
95
+ /** How often the signer's files are re-checked. 0: never on a timer (tests call integrity.check()). */
96
+ recheckMs?: number
97
+ }
98
+
99
+ function applyHeaders(res: ServerResponse): void {
100
+ for (const [k, v] of Object.entries(WEB_SIGNER_HEADERS)) res.setHeader(k, v)
101
+ }
102
+
103
+ function send(res: ServerResponse, status: number, type: string, body: string | Buffer, head = false): void {
104
+ res.statusCode = status
105
+ res.setHeader('Content-Type', type)
106
+ const buf = typeof body === 'string' ? Buffer.from(body, 'utf8') : body
107
+ res.setHeader('Content-Length', String(buf.length))
108
+ res.end(head ? undefined : buf)
109
+ }
110
+
111
+ function json(res: ServerResponse, status: number, value: Record<string, unknown>): void {
112
+ send(res, status, 'application/json; charset=utf-8', JSON.stringify(value))
113
+ }
114
+
115
+ export function createWebKeySignerHandler(deps: WebKeySignerDeps): ((req: IncomingMessage, res: ServerResponse) => void) & { stop: () => void } {
116
+ let timer: ReturnType<typeof setInterval> | null = null
117
+ const every = deps.recheckMs ?? 60_000
118
+ if (deps.integrity && every > 0) {
119
+ const integrity = deps.integrity
120
+ timer = setInterval(() => { try { integrity.check() } catch { /* the next tick retries */ } }, every)
121
+ timer.unref?.()
122
+ }
123
+
124
+ const handler = (req: IncomingMessage, res: ServerResponse): void => {
125
+ applyHeaders(res)
126
+ let pathname: string
127
+ try { pathname = new URL(req.url ?? '/', 'https://signer.invalid').pathname } catch { json(res, 400, { ok: false, error: 'invalid_request' }); return }
128
+ const method = String(req.method ?? 'GET').toUpperCase()
129
+
130
+ if (pathname === '/api' || pathname.startsWith('/api/')) {
131
+ const expected = deps.origin()
132
+ const origin = typeof req.headers.origin === 'string' ? req.headers.origin : undefined
133
+ // Another origin's page never drives this one. A write must name this origin outright.
134
+ if (origin !== undefined && origin !== expected) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
135
+ if (method !== 'GET' && method !== 'HEAD' && (expected === null || origin !== expected)) { json(res, 403, { ok: false, error: 'origin_refused' }); return }
136
+ if (!signerApiAllowed(method === 'HEAD' ? 'GET' : method, pathname)) { json(res, 404, { ok: false, error: 'not_found' }); return }
137
+ deps.api(req, res)
138
+ return
139
+ }
140
+
141
+ if (method !== 'GET' && method !== 'HEAD') { res.setHeader('Allow', 'GET, HEAD'); json(res, 405, { ok: false, error: 'method_not_allowed' }); return }
142
+ const head = method === 'HEAD'
143
+ const snapshot = deps.integrity ? deps.integrity.snapshot() : null
144
+ if (!snapshot) { send(res, 503, 'text/html; charset=utf-8', SIGNER_PAUSED_PAGE, head); return }
145
+ const file = pathname === '/' ? '/index.html' : pathname
146
+ const ext = file.slice(file.lastIndexOf('.')).toLowerCase()
147
+ const body = TYPES[ext] ? snapshot.get(file) : undefined
148
+ if (!body) { send(res, 404, 'text/plain; charset=utf-8', 'Not found', head); return }
149
+ send(res, 200, TYPES[ext], body, head)
150
+ }
151
+ return Object.assign(handler, { stop: () => { if (timer) clearInterval(timer); timer = null } })
152
+ }
@@ -1,4 +1,4 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
2
  import { mkdirSync, readFileSync } from 'node:fs'
3
3
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
4
4
  import { dirname } from 'node:path'
@@ -19,6 +19,12 @@ import { CROCKFORD_ALPHABET, PAIRING_CODE_ATTEMPT_LIMIT, PAIRING_CODE_LENGTH } f
19
19
  export const WEB_SESSION_COOKIE_NAME = 'cos_web_session'
20
20
  export const WEB_CSRF_HEADER = 'x-cos-web'
21
21
  export const WEB_SESSION_TTL_MS = 7 * 24 * 60 * 60_000
22
+ /**
23
+ * 7.0.x sign-in renewal (Miles 2026-11-09, "Renew while in use, 30-day cap"). A browser that is used in the last
24
+ * RENEW_WINDOW of its cookie gets a fresh one, never running past MAX_LIFE from the time it first signed in.
25
+ */
26
+ export const WEB_SESSION_RENEW_WINDOW_MS = 2 * 24 * 60 * 60_000
27
+ export const WEB_SESSION_MAX_LIFE_MS = 30 * 24 * 60 * 60_000
22
28
  export const WEB_HANDOFF_TTL_MS = 2 * 60_000
23
29
  export const WEB_HANDOFF_MAX_ACTIVE = 16
24
30
 
@@ -43,6 +49,17 @@ function signSession(apiToken: string, expUnixSec: number, epoch: number): strin
43
49
  .digest('hex')
44
50
  }
45
51
 
52
+ /**
53
+ * A RENEWED cookie also carries the time the browser first signed in (`orig`), so no renewal can pass the 30-day cap.
54
+ * Same key as above (derived from the API token: no new secret); the signed text starts with `v3:` and names `orig`,
55
+ * so a 3-part signature can never stand for a 4-part cookie, and `orig` cannot be edited without breaking it.
56
+ */
57
+ function signRenewedSession(apiToken: string, expUnixSec: number, epoch: number, origUnixSec: number): string {
58
+ return createHmac('sha256', `cos_web_session_v2:${apiToken}`)
59
+ .update(`v3:${expUnixSec}:${epoch}:${origUnixSec}`)
60
+ .digest('hex')
61
+ }
62
+
46
63
  // ── Revocation (review W1) ───────────────────────────────────────────────────
47
64
  // The cookie signs a session EPOCH kept on the Mac. "Sign out every browser" bumps it, so
48
65
  // every copy of every cookie stops working at once, without rotating the pairing token
@@ -117,19 +134,66 @@ export function verifyWebSessionCookieValue(
117
134
  nowMs = Date.now(),
118
135
  epoch = currentWebSessionEpoch(),
119
136
  ): boolean {
120
- if (typeof cookieValue !== 'string' || !apiToken) return false
137
+ return readWebSessionCookie(cookieValue, apiToken, nowMs, epoch) !== null
138
+ }
139
+
140
+ /**
141
+ * The verified cookie's expiry and first sign-in time (unix seconds), or null for any cookie that does not verify.
142
+ * Two shapes verify:
143
+ * `<exp>.<epoch>.<sig>` minted at sign-in (unchanged). Its first sign-in time is its issue time, exp minus 7 days.
144
+ * `<exp>.<epoch>.<orig>.<sig>` minted by a renewal; exp may never pass orig plus 30 days, and orig is never in the future.
145
+ */
146
+ export function readWebSessionCookie(
147
+ cookieValue: string | undefined,
148
+ apiToken: string,
149
+ nowMs = Date.now(),
150
+ epoch = currentWebSessionEpoch(),
151
+ ): { expUnixSec: number; origUnixSec: number; renewed: boolean } | null {
152
+ if (typeof cookieValue !== 'string' || !apiToken) return null
121
153
  const parts = cookieValue.split('.')
122
- if (parts.length !== 3) return false
123
- const [expPart, epochPart, sigPart] = parts
124
- if (!/^\d{1,15}$/.test(expPart) || !/^\d{1,12}$/.test(epochPart) || !/^[0-9a-f]{64}$/.test(sigPart)) return false
154
+ if (parts.length !== 3 && parts.length !== 4) return null
155
+ const renewed = parts.length === 4
156
+ const expPart = parts[0], epochPart = parts[1], origPart = renewed ? parts[2] : null, sigPart = parts[parts.length - 1]
157
+ if (!/^\d{1,15}$/.test(expPart) || !/^\d{1,12}$/.test(epochPart) || !/^[0-9a-f]{64}$/.test(sigPart)) return null
158
+ if (origPart !== null && !/^\d{1,15}$/.test(origPart)) return null
125
159
  const expUnixSec = Number(expPart)
126
- if (!Number.isSafeInteger(expUnixSec) || expUnixSec * 1000 <= nowMs) return false
160
+ if (!Number.isSafeInteger(expUnixSec) || expUnixSec * 1000 <= nowMs) return null
161
+ const origUnixSec = origPart !== null ? Number(origPart) : expUnixSec - Math.floor(WEB_SESSION_TTL_MS / 1000)
162
+ if (!Number.isSafeInteger(origUnixSec)) return null
163
+ if (renewed && (origUnixSec * 1000 > nowMs || expUnixSec > origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000))) return null
127
164
  // The signature covers the CURRENT epoch, so a cookie from before "Sign out every browser"
128
165
  // (or one whose epoch digits were edited) fails here; epochPart is only shape-checked.
129
- const expected = signSession(apiToken, expUnixSec, epoch)
166
+ const expected = renewed ? signRenewedSession(apiToken, expUnixSec, epoch, origUnixSec) : signSession(apiToken, expUnixSec, epoch)
130
167
  const left = Buffer.from(sigPart, 'utf8')
131
168
  const right = Buffer.from(expected, 'utf8')
132
- return left.length === right.length && timingSafeEqual(left, right)
169
+ if (left.length !== right.length || !timingSafeEqual(left, right)) return null
170
+ return { expUnixSec, origUnixSec, renewed }
171
+ }
172
+
173
+ /**
174
+ * The renewal for a cookie that ALREADY verified, or null. Only in the last 2 days of its life, and never past 30 days
175
+ * from its first sign-in: the new expiry is the earlier of now plus 7 days and that cap, and a renewal that would not
176
+ * move the expiry later is not made. The caller (the /api gate) calls this only after every other check passed.
177
+ */
178
+ export function renewWebSessionCookieValue(
179
+ cookieValue: string | undefined,
180
+ apiToken: string,
181
+ nowMs = Date.now(),
182
+ epoch = currentWebSessionEpoch(),
183
+ ): { value: string; expiresAtMs: number; capAtMs: number } | null {
184
+ const read = readWebSessionCookie(cookieValue, apiToken, nowMs, epoch)
185
+ if (!read) return null
186
+ if (read.expUnixSec * 1000 - nowMs > WEB_SESSION_RENEW_WINDOW_MS) return null
187
+ const capUnixSec = read.origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)
188
+ const expUnixSec = Math.min(Math.floor((nowMs + WEB_SESSION_TTL_MS) / 1000), capUnixSec)
189
+ if (expUnixSec <= read.expUnixSec) return null
190
+ const sig = signRenewedSession(apiToken, expUnixSec, epoch, read.origUnixSec)
191
+ return { value: `${expUnixSec}.${epoch}.${read.origUnixSec}.${sig}`, expiresAtMs: expUnixSec * 1000, capAtMs: capUnixSec * 1000 }
192
+ }
193
+
194
+ /** The raw session cookie on a request header, for the renewal (lib/api-auth.ts) and the status route. */
195
+ export function webSessionCookieFrom(cookieHeader: unknown): string | undefined {
196
+ return parseCookies(typeof cookieHeader === 'string' ? cookieHeader : undefined)[WEB_SESSION_COOKIE_NAME]
133
197
  }
134
198
 
135
199
  export function formatSetWebSessionCookie(value: string, maxAgeSec: number, secure = false): string {
@@ -249,13 +313,36 @@ interface HandoffRecord {
249
313
  * open the user's browser directly into an authenticated session without putting
250
314
  * the standing `COS_API_TOKEN` in a URL or touching `PairingState`.
251
315
  */
316
+ /**
317
+ * Why a handoff ticket did not sign in (S1 item 7, lead 20:11). Logged only; the person sees the same sentence for all.
318
+ * expired its 2 minutes ran out used it already signed a browser in
319
+ * evicted more than 16 were open unknown never issued here, or issued before this server started
320
+ */
321
+ export type WebHandoffFailure = 'expired' | 'used' | 'evicted' | 'revoked' | 'unknown'
322
+ /** A retired ticket is remembered (as a SHA-256, never the ticket) this long and at most this many, only to say why. */
323
+ export const WEB_HANDOFF_RETIRED_MS = 60 * 60_000
324
+ export const WEB_HANDOFF_RETIRED_MAX = 256
325
+
252
326
  export class WebHandoffStore {
253
327
  private readonly tickets = new Map<string, HandoffRecord>()
328
+ private readonly retired = new Map<string, { why: Exclude<WebHandoffFailure, 'unknown'>; at: number }>()
254
329
  constructor(private readonly now: () => number = () => Date.now()) {}
255
330
 
331
+ private static digest(ticket: string): string { return createHash('sha256').update(ticket).digest('hex') }
332
+
333
+ private retire(ticket: string, why: Exclude<WebHandoffFailure, 'unknown'>, at: number): void {
334
+ const key = WebHandoffStore.digest(ticket)
335
+ this.retired.delete(key)
336
+ this.retired.set(key, { why, at })
337
+ for (const [k, rec] of this.retired) {
338
+ if (this.retired.size <= WEB_HANDOFF_RETIRED_MAX && at - rec.at < WEB_HANDOFF_RETIRED_MS) break
339
+ this.retired.delete(k)
340
+ }
341
+ }
342
+
256
343
  private sweep(at: number): void {
257
344
  for (const [k, rec] of this.tickets) {
258
- if (at >= rec.expiresAtMs) this.tickets.delete(k)
345
+ if (at >= rec.expiresAtMs) { this.tickets.delete(k); this.retire(k, 'expired', at) }
259
346
  }
260
347
  }
261
348
 
@@ -266,6 +353,7 @@ export class WebHandoffStore {
266
353
  const oldest = this.tickets.keys().next().value
267
354
  if (!oldest) break
268
355
  this.tickets.delete(oldest)
356
+ this.retire(oldest, 'evicted', at)
269
357
  }
270
358
  const ticket = randomBytes(24).toString('base64url')
271
359
  const expiresAtMs = at + ttlMs
@@ -274,13 +362,35 @@ export class WebHandoffStore {
274
362
  }
275
363
 
276
364
  consume(ticket: unknown): boolean {
365
+ return this.consumeWithReason(ticket).ok
366
+ }
367
+
368
+ /** As `consume`, and on failure why. Single use and expiry are unchanged. */
369
+ consumeWithReason(ticket: unknown): { ok: true } | { ok: false; why: WebHandoffFailure } {
277
370
  const at = this.now()
278
371
  this.sweep(at)
279
- if (typeof ticket !== 'string' || !HANDOFF_TICKET_PATTERN.test(ticket)) return false
372
+ if (typeof ticket !== 'string' || !HANDOFF_TICKET_PATTERN.test(ticket)) return { ok: false, why: 'unknown' }
280
373
  const existing = this.tickets.get(ticket)
281
- if (!existing) return false
374
+ if (!existing) {
375
+ const past = this.retired.get(WebHandoffStore.digest(ticket))
376
+ return { ok: false, why: past && at - past.at < WEB_HANDOFF_RETIRED_MS ? past.why : 'unknown' }
377
+ }
282
378
  this.tickets.delete(ticket)
283
- return at < existing.expiresAtMs
379
+ if (at >= existing.expiresAtMs) { this.retire(ticket, 'expired', at); return { ok: false, why: 'expired' } }
380
+ this.retire(ticket, 'used', at)
381
+ return { ok: true }
382
+ }
383
+
384
+ /**
385
+ * Final review V7 (lead 2026-11-10 02:18): "Sign out every browser" also ends every sign-in link not yet used, so
386
+ * one made just before it cannot sign a browser in after it. Each is remembered as `revoked` for the log. Returns
387
+ * how many it ended.
388
+ */
389
+ revokeAll(): number {
390
+ const at = this.now()
391
+ const live = [...this.tickets.keys()]
392
+ for (const ticket of live) { this.tickets.delete(ticket); this.retire(ticket, 'revoked', at) }
393
+ return live.length
284
394
  }
285
395
  }
286
396
 
@@ -289,7 +399,7 @@ export type WebLoginFailure = 'wrong_token' | 'wrong_code' | 'glasses_code' | 'h
289
399
 
290
400
  export type WebLoginResult =
291
401
  | { ok: true; kind: 'token' | 'handoff' | 'code' }
292
- | { ok: false; reason: WebLoginFailure; length: number }
402
+ | { ok: false; reason: WebLoginFailure; length: number; handoff?: WebHandoffFailure }
293
403
 
294
404
  // What a phone paste drags along with a token: spaces and line breaks (a copy out of a
295
405
  // terminal or a note), zero-width and no-break characters, and the quotes around a value
@@ -327,9 +437,10 @@ export function classifyWebLogin(
327
437
  }
328
438
  if (handoffStore && typeof input.handoff === 'string' && input.handoff.trim().length > 0) {
329
439
  const ticket = input.handoff.trim()
330
- return handoffStore.consume(ticket)
440
+ const used = handoffStore.consumeWithReason(ticket)
441
+ return used.ok
331
442
  ? { ok: true, kind: 'handoff' }
332
- : { ok: false, reason: 'handoff_expired', length: ticket.length }
443
+ : { ok: false, reason: 'handoff_expired', length: ticket.length, handoff: used.why }
333
444
  }
334
445
  if (codeStore && typeof input.code === 'string' && input.code.trim().length > 0) {
335
446
  if (codeStore.consume(input.code)) return { ok: true, kind: 'code' }
@@ -437,6 +548,13 @@ export class WebDeviceCodeStore {
437
548
  this.codes.delete(code)
438
549
  return true
439
550
  }
551
+
552
+ /** Final review V7: "Sign out every browser" also ends every sign-in code not yet typed. Returns how many it ended. */
553
+ revokeAll(): number {
554
+ const n = this.codes.size
555
+ this.codes.clear()
556
+ return n
557
+ }
440
558
  }
441
559
 
442
560
  export const WEB_LOGIN_MAX_FAILURES = 8
@@ -764,18 +764,66 @@ async function startWhisperServerAttempt(preflightCompleted = false): Promise<vo
764
764
  /**
765
765
  * Gracefully stop whisper-server (called on process exit).
766
766
  */
767
- export function stopWhisperServer(): void {
768
- if (serverProcess) {
769
- serverProcess.kill('SIGTERM')
767
+ /** How whisper-server is stopped, and why it is never SIGTERM (S1, 2026-11-09).
768
+ * whisper-server's own SIGTERM/SIGINT handler calls exit(); exit runs static destructors,
769
+ * and ggml's Metal device teardown aborts in ggml_metal_rsets_free. Nine crash reports
770
+ * 11/03 to 11/08 show exactly that stack (signal_handler, exit, __cxa_finalize_ranges,
771
+ * ggml_metal_device_free, ggml_abort), each in the minute of a COS server restart, with the
772
+ * parent already launchd because node exited without waiting. The daemon holds no state
773
+ * worth flushing (the model is on disk, requests are retried by their callers), so SIGKILL
774
+ * stops it without running that teardown and leaves no crash report. The stop then waits
775
+ * for the close, so node never exits first and orphans the child. */
776
+ export const WHISPER_STOP_SIGNAL: NodeJS.Signals = 'SIGKILL'
777
+ export const WHISPER_STOP_WAIT_MS = 2_000
778
+
779
+ /** Resolve true when `child` has closed, false after `timeoutMs`. */
780
+ export function waitForChildClose(child: Pick<ChildProcess, 'exitCode' | 'signalCode' | 'once' | 'off'>, timeoutMs: number): Promise<boolean> {
781
+ if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
782
+ return new Promise(resolvePromise => {
783
+ let settled = false
784
+ const finish = (closed: boolean) => {
785
+ if (settled) return
786
+ settled = true
787
+ clearTimeout(timer)
788
+ child.off('close', onClose)
789
+ resolvePromise(closed)
790
+ }
791
+ const onClose = () => finish(true)
792
+ const timer = setTimeout(() => finish(false), timeoutMs)
793
+ timer.unref?.()
794
+ child.once('close', onClose)
795
+ })
796
+ }
797
+
798
+ export interface WhisperStopResult { stopped: boolean; exited: boolean; waitedMs: number; hqChildren: number }
799
+
800
+ export async function stopWhisperServer(options: { waitMs?: number } = {}): Promise<WhisperStopResult> {
801
+ const waitMs = options.waitMs ?? WHISPER_STOP_WAIT_MS
802
+ const started = Date.now()
803
+ const waits: Array<Promise<boolean>> = []
804
+ const child = serverProcess
805
+ if (child) {
770
806
  serverProcess = null
771
807
  serverAvailable = false
772
- console.log('[whisper-local] whisper-server stopped')
808
+ try { child.kill(WHISPER_STOP_SIGNAL) } catch { /* already exited */ }
809
+ waits.push(waitForChildClose(child, waitMs))
773
810
  }
774
- for (const proc of ownedHqChildren) {
811
+ const hq = [...ownedHqChildren]
812
+ for (const proc of hq) {
775
813
  try { proc.kill('SIGKILL') } catch { /* already exited */ }
814
+ waits.push(waitForChildClose(proc, waitMs))
776
815
  }
777
816
  ownedHqChildren.clear()
778
817
  serverStartupState = 'stopped'
818
+ const closed = await Promise.all(waits)
819
+ const exited = closed.every(Boolean)
820
+ const waitedMs = Date.now() - started
821
+ if (child || hq.length) {
822
+ // Q1 S4: name what was actually running.
823
+ const what = child ? `whisper-server stopped (${WHISPER_STOP_SIGNAL}, ${hq.length} HQ child(ren))` : `${hq.length} HQ child(ren) stopped (SIGKILL; whisper-server was not running)`
824
+ console.log(`[whisper-local] ${what}: ` + (exited ? `exited in ${waitedMs} ms` : `did not exit within ${waitMs} ms`))
825
+ }
826
+ return { stopped: Boolean(child), exited, waitedMs, hqChildren: hq.length }
779
827
  }
780
828
 
781
829
  export function isWhisperLocalAvailable(): boolean {
@@ -952,6 +1000,36 @@ export function parseWhisperCliFullJson(raw: string): { text: string; words: Whi
952
1000
  }
953
1001
  }
954
1002
 
1003
+ /** S1 (2026-11-09): an HQ decode that hit its wall-clock ceiling. The message keeps the
1004
+ * historical prefix ("whisper-cli HQ timeout (240s, model=large-v3)") that log scans count,
1005
+ * then names what settles why: the audio length, the device and why, the thread count and
1006
+ * whether the child ran at background QoS (taskpolicy -b, which keeps it off the
1007
+ * performance cores). Field evidence 10/26 to 11/09: 290 progressive-checkpoint timeouts
1008
+ * against 190 successes, every one on CPU at background QoS. */
1009
+ export class WhisperHqTimeoutError extends Error {
1010
+ readonly hqTimeout = true
1011
+ constructor(
1012
+ readonly timeoutMs: number,
1013
+ readonly model: 'large-v3' | 'turbo',
1014
+ readonly details: { audioSeconds: number; device: 'metal' | 'cpu'; reason: string; threads: number; backgroundQos: boolean; elapsedMs: number },
1015
+ ) {
1016
+ super(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${model}) `
1017
+ + `audio=${details.audioSeconds.toFixed(1)}s device=${details.device} reason=${details.reason} `
1018
+ + `threads=${details.threads} qos=${details.backgroundQos ? 'background' : 'default'} elapsed=${details.elapsedMs}ms`)
1019
+ this.name = 'WhisperHqTimeoutError'
1020
+ }
1021
+ }
1022
+
1023
+ export function isWhisperHqTimeout(error: unknown): error is WhisperHqTimeoutError {
1024
+ return error instanceof WhisperHqTimeoutError
1025
+ || (typeof error === 'object' && error !== null && (error as { hqTimeout?: boolean }).hqTimeout === true)
1026
+ }
1027
+
1028
+ /** Seconds of audio in a 16 kHz mono 16-bit WAV (the only shape the HQ path decodes). */
1029
+ export function wavAudioSeconds(buffer: Buffer): number {
1030
+ return Math.max(0, buffer.length - 44) / 32_000
1031
+ }
1032
+
955
1033
  /**
956
1034
  * High-quality transcription for batch/post-meeting use.
957
1035
  * Uses the full Whisper large-v3 (32-layer) decoder + Silero VAD + beam search.
@@ -1152,7 +1230,14 @@ export async function transcribeHighQuality(
1152
1230
  return
1153
1231
  }
1154
1232
  if (timedOut) {
1155
- reject(new Error(`whisper-cli HQ timeout (${timeoutMs / 1000}s, model=${useLargeV3 ? 'large-v3' : 'turbo'})`))
1233
+ reject(new WhisperHqTimeoutError(timeoutMs, useLargeV3 ? 'large-v3' : 'turbo', {
1234
+ audioSeconds: wavAudioSeconds(audioBuffer),
1235
+ device: decision.device,
1236
+ reason: decision.reason,
1237
+ threads: (isBatch && !useMetal) ? requestedThreads : 16,
1238
+ backgroundQos: useBackgroundTaskPolicy,
1239
+ elapsedMs: Date.now() - start,
1240
+ }))
1156
1241
  return
1157
1242
  }
1158
1243
  if (code !== 0) {
@@ -1215,7 +1300,8 @@ export async function transcribeHighQuality(
1215
1300
  const modelTag = useLargeV3 ? 'large-v3' : 'turbo'
1216
1301
  console.log(
1217
1302
  `[whisper-hq] Batch transcribed in ${elapsed}ms ` +
1218
- `(${modelTag}${useVad ? '+vad' : ''}${captureWords ? '+words' : ''}` +
1303
+ // S1: the audio length makes the real-time factor readable from the log.
1304
+ `(${modelTag}${useVad ? '+vad' : ''}${captureWords ? '+words' : ''}, audio=${wavAudioSeconds(audioBuffer).toFixed(1)}s` +
1219
1305
  `${words ? `, ${words.length} words` : ''}` +
1220
1306
  // Device forensics: without these, "why is polish slow today" is
1221
1307
  // unanswerable after the fact.
@@ -446,10 +446,12 @@ export async function stopWhisperPreviewServer(options: { final?: boolean } = {}
446
446
  previewStarting = false
447
447
  previewWorkerModel = null
448
448
  if (child) {
449
- try { child.kill('SIGTERM') } catch { /* already exited */ }
449
+ // S1 (2026-11-09): never SIGTERM. The sidecar is the same whisper-server binary, whose
450
+ // SIGTERM handler exits through ggml's Metal teardown and aborts (see whisper-local.ts
451
+ // WHISPER_STOP_SIGNAL). SIGKILL leaves no crash report; then wait so node never exits first.
452
+ try { child.kill('SIGKILL') } catch { /* already exited */ }
450
453
  if (!await waitForPreviewClose(child, 2_000)) {
451
- try { child.kill('SIGKILL') } catch { /* already exited */ }
452
- await waitForPreviewClose(child, 1_000)
454
+ console.warn('[whisper-preview] sidecar did not exit within 2000 ms of SIGKILL')
453
455
  }
454
456
  }
455
457
  }
@@ -34,6 +34,18 @@ export const epochSeconds = (v: unknown): number | null => typeof v === 'number'
34
34
  const sidOrNull = (v: unknown): string | null => typeof v === 'string' && SID.test(v.toLowerCase()) ? v.toLowerCase() : null
35
35
 
36
36
  export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
37
+ return parseSnapshotReport(v, now).snapshot
38
+ }
39
+
40
+ /**
41
+ * 7.0.13: the snapshot plus how many live rows were left out. Live rows are evidence only (what the session list
42
+ * showed), so a row COS cannot name by a full session id, or whose text breaks a bound, is skipped and counted instead
43
+ * of refusing the whole snapshot. Control 0.5.384 sent a running Claude CLI peer as `claude:<8 hex>` (the prefix id
44
+ * /api/claude-sessions serves) and every Work chat plan on every card failed with 400 invalid_snapshot. A row that is
45
+ * not an object, and every link and receipt, stay strict: links and receipts decide which sessions belong to a card.
46
+ */
47
+ export function parseSnapshotReport(v: unknown, now = Date.now()): { snapshot: ControlSnapshot; droppedLiveRows: number } {
48
+ let droppedLiveRows = 0
37
49
  if (!obj(v) || v.version !== SNAPSHOT_VERSION || !str(v.controlVersion, 40) || !Array.isArray(v.links) || v.links.length > SNAPSHOT_MAX_LINKS
38
50
  || !Array.isArray(v.receipts) || v.receipts.length > SNAPSHOT_MAX_RECEIPTS) throw new Error('invalid_snapshot')
39
51
  const links: SnapshotLink[] = []
@@ -56,15 +68,17 @@ export function parseSnapshot(v: unknown, now = Date.now()): ControlSnapshot {
56
68
  if (!obj(v.live) || checkedAt === null || !Array.isArray(v.live.rows) || v.live.rows.length > SNAPSHOT_MAX_LIVE) throw new Error('invalid_live')
57
69
  const rows: SnapshotLiveRow[] = []
58
70
  for (const r of v.live.rows) {
59
- const id = sidOrNull(r?.id)
60
- if (!obj(r) || !id || !str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) throw new Error('invalid_live_row')
71
+ if (!obj(r)) throw new Error('invalid_live_row')
72
+ const id = sidOrNull(r.id)
73
+ if (!id) { droppedLiveRows++; continue }
74
+ if (!str(r.title ?? '', 300) || !str(r.status ?? '', 100) || !str(r.summary ?? '', 2000)) { droppedLiveRows++; continue }
61
75
  if (isGuardianRow(id, r.title ?? '', r.summary ?? '')) continue
62
76
  rows.push({ id, title: r.title ?? '', status: r.status ?? '', updatedAt: str(r.updatedAt, 40) ? r.updatedAt : null,
63
77
  busy: r.busy === true, waitingOnUser: r.waitingOnUser === true, appOwned: r.appOwned === true, summary: r.summary ?? '' })
64
78
  }
65
79
  live = { checkedAt, rows }
66
80
  }
67
- return { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }
81
+ return { snapshot: { version: 1, controlVersion: v.controlVersion, sentAt: epochSeconds(v.sentAt) ?? now / 1000, receivedAt: now / 1000, links, receipts, live }, droppedLiveRows }
68
82
  }
69
83
 
70
84
  /** Codex guardian review threads (canary C3: 17 in the live list) are never offered as work sessions. */
@@ -112,6 +112,19 @@ export class WorkReviewRuntime {
112
112
  return { review: publicReview(await this.reconcileRow(row, true)), replayed: false }
113
113
  })
114
114
  }
115
+ /** S1 (2026-11-09): write a reconciled row only when something other than updatedAt changed.
116
+ * The 5 s timer reconciles every row; a row whose meeting stays unavailable came back identical
117
+ * except for a fresh updatedAt, and each put is an fsync'd rewrite of the whole journal. On
118
+ * Miles's Mac three such rows rewrote the 114 KB journal three times every 5 s (about 68 KB/s,
119
+ * some 60% of the server's idle disk writes). An unchanged row keeps its original updatedAt. */
120
+ private saveIfChanged(before: StoredWorkReview, after: StoredWorkReview): StoredWorkReview {
121
+ const { updatedAt: _a, ...left } = before
122
+ const { updatedAt: _b, ...right } = after
123
+ if (JSON.stringify(left) === JSON.stringify(right)) return before
124
+ after.updatedAt = this.now()
125
+ this.deps.store.put(after)
126
+ return after
127
+ }
115
128
  private async reconcileRow(row: StoredWorkReview, allowAdmission: boolean): Promise<StoredWorkReview> {
116
129
  if (row.status === 'superseded') return row
117
130
  const updated = structuredClone(row)
@@ -119,12 +132,12 @@ export class WorkReviewRuntime {
119
132
  const current = await this.deps.resolveMeeting(row.source.descriptor); assertRecord(current, row.source.descriptor)
120
133
  if (sourceRevision(current) !== row.source.revision) {
121
134
  updated.status = 'superseded'; updated.error = { code: 'meeting_revision_changed', message: 'The saved meeting changed. Request a new review for its current revision.' }
122
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
135
+ return this.saveIfChanged(row, updated)
123
136
  }
124
137
  } catch {
125
138
  if (row.status === 'ready' || row.status === 'failed') updated.suspendedTerminal = { status: row.status, ...(row.error ? { error: row.error } : {}) }
126
139
  updated.status = 'source_unavailable'; updated.error = { code: 'meeting_source_unavailable', message: 'The original saved source cannot be verified. No new work was started.' }
127
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
140
+ return this.saveIfChanged(row, updated)
128
141
  }
129
142
  if (row.status === 'source_unavailable' && row.suspendedTerminal) {
130
143
  // Terminal receipts outlive query-job retention. Restore only after the
@@ -132,7 +145,7 @@ export class WorkReviewRuntime {
132
145
  updated.status = row.suspendedTerminal.status
133
146
  if (row.suspendedTerminal.error) updated.error = row.suspendedTerminal.error; else delete updated.error
134
147
  delete updated.suspendedTerminal
135
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
148
+ return this.saveIfChanged(row, updated)
136
149
  }
137
150
  if (row.status === 'ready' || row.status === 'failed') return row
138
151
  try {
@@ -170,7 +183,7 @@ export class WorkReviewRuntime {
170
183
  if (updated.status === 'ready') { updated.status = 'source_unavailable'; delete updated.markdown }
171
184
  updated.error = { code: e instanceof WorkReviewError ? e.code : 'review_reconciliation_unavailable', message: 'Review reconciliation is unavailable. No automatic duplicate is submitted.' }
172
185
  }
173
- updated.updatedAt = this.now(); this.deps.store.put(updated); return updated
186
+ return this.saveIfChanged(row, updated)
174
187
  }
175
188
  async reconcile(allowAdmission = false): Promise<void> {
176
189
  await this.serial(async () => { if (this.stopping) return; for (const row of this.deps.store.list()) await this.reconcileRow(row, allowAdmission) })