@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
@@ -1,7 +1,7 @@
1
1
  import type { RequestHandler } from 'express'
2
2
  import { timingSafeTokenEqual } from './token-auth.js'
3
3
  import { PAIRING_NONCE_BODY } from './glasses-pairing.js'
4
- import { isWebSessionAuthorized } from './web-session-auth.js'
4
+ import { formatSetWebSessionCookie, isWebSessionAuthorized, renewWebSessionCookieValue, verifyWebSessionCookieValue, webSessionCookieFrom } from './web-session-auth.js'
5
5
 
6
6
  // Recovery/setup clients need these availability surfaces before they have a
7
7
  // usable token. Keep private provider state and every mutation route out.
@@ -44,9 +44,31 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
44
44
  // Minting, status and decisions stay behind the token AND a loopback socket.
45
45
  const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
46
46
 
47
+ /** Set on a response whose request renewed its browser sign-in: `<new expiry ISO> <30-day cap ISO>`. The page reads it. */
48
+ export const WEB_SESSION_RENEWED_HEADER = 'X-COS-Web-Session'
49
+
47
50
  /** `res.locals` key the gate sets to `'token'` when the X-COS-Token header matched. */
48
51
  export const API_AUTH_BY_LOCAL = 'cosApiAuthBy'
49
52
 
53
+ /**
54
+ * Final review S1 (lead 2026-11-10 02:09): `res.locals` key the gate sets ONLY for a request it let in by the browser's
55
+ * sign-in cookie: a check that the same cookie still verifies now (its expiry, the epoch "Sign out every browser" bumps,
56
+ * the 30-day cap). A stream that outlives its opening request calls it on every heartbeat (`streamSignInHolds`).
57
+ */
58
+ export const WEB_SESSION_RECHECK_LOCAL = 'cosWebSessionRecheck'
59
+
60
+ /**
61
+ * For a long-lived stream, on each heartbeat: false once the browser sign-in that opened it no longer verifies, so the
62
+ * stream tears down. A request the API token let in has no re-check and always holds, as does one that never passed
63
+ * the gate (a route mounted without it in a test). The browser reconnects with whatever cookie it holds now; a renewed
64
+ * one verifies there.
65
+ */
66
+ export function streamSignInHolds(res: { locals?: Record<string, unknown> }): boolean {
67
+ const recheck = res.locals?.[WEB_SESSION_RECHECK_LOCAL]
68
+ if (typeof recheck !== 'function') return true
69
+ try { return recheck() === true } catch { return false }
70
+ }
71
+
50
72
  export function isPublicApiRequest(method: string, path: string): boolean {
51
73
  if (PUBLIC_API_PATHS.has(path)) return true
52
74
  if (method === 'POST' && path === '/pairing/claim') return true
@@ -92,6 +114,20 @@ export function requireApiToken(apiToken: string, options: RequireApiTokenOption
92
114
  allowedPorts: options.allowedPorts?.(),
93
115
  })
94
116
  if (cookieAuth) {
117
+ // Final review S1: the cookie this request came in with, checked again by any stream it opens (above).
118
+ const opened = webSessionCookieFrom(req.headers.cookie)
119
+ res.locals[WEB_SESSION_RECHECK_LOCAL] = (): boolean => verifyWebSessionCookieValue(opened, apiToken, Date.now())
120
+ // 7.0.x (Miles 2026-11-09, "Renew while in use, 30-day cap"): only here, after the socket, cookie, CSRF and Origin
121
+ // checks above all passed, a cookie in its last 2 days gets a fresh one, never past 30 days from its first sign-in.
122
+ // Appended, so a route's own Set-Cookie (none today on gated routes) is never overwritten. A renewal that cannot
123
+ // be made (outside the window, at the cap) changes nothing.
124
+ try {
125
+ const renewed = renewWebSessionCookieValue(webSessionCookieFrom(req.headers.cookie), apiToken)
126
+ if (renewed) {
127
+ res.append('Set-Cookie', formatSetWebSessionCookie(renewed.value, Math.floor((renewed.expiresAtMs - Date.now()) / 1000), req.secure))
128
+ res.setHeader(WEB_SESSION_RENEWED_HEADER, new Date(renewed.expiresAtMs).toISOString() + ' ' + new Date(renewed.capAtMs).toISOString())
129
+ }
130
+ } catch { /* a failed renewal leaves the request and the old cookie as they were */ }
95
131
  return next()
96
132
  }
97
133
  return res.status(401).json({
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Today round 2 (2026-11-09): which calendar rows are distinct, real meetings. ONE helper for the calendar API
3
+ * (calendar-source.ts calendarToday: next, remainingCount, endedRecently) and the Today summary (today-brief.ts
4
+ * withCalendar), so the two never disagree.
5
+ *
6
+ * - Canceled: a title that starts "Canceled:" or "Cancelled:" (how Exchange and Google rename a canceled occurrence),
7
+ * or a row whose `status` says canceled, or `canceled: true` (the snapshot's EventKit status, when it carries one;
8
+ * the 11/09 snapshot had no status field on any of its 154 events).
9
+ * - Distinct: the same title (trimmed, case-folded, spaces collapsed) at the same start is one meeting, whichever
10
+ * calendar it is on. The 11/09 live snapshot had 13 such pairs or triples, 12 of them on ONE Exchange calendar with
11
+ * different external ids (the same invite delivered twice), so "across calendars" alone would have left 12 of them.
12
+ * The first row of each wins, so callers keep their own order.
13
+ * No dependencies: the summary imports it without the calendar reader.
14
+ */
15
+ export const CANCELED_TITLE = /^\s*(?:canceled|cancelled)\s*:/i
16
+ const CANCELED_STATUS = new Set(['canceled', 'cancelled'])
17
+
18
+ export interface MeetingFields { title: unknown; start: unknown; status?: unknown; canceled?: unknown }
19
+
20
+ export function isCanceledMeeting(row: MeetingFields): boolean {
21
+ if (row.canceled === true) return true
22
+ if (typeof row.status === 'string' && CANCELED_STATUS.has(row.status.trim().toLowerCase())) return true
23
+ return typeof row.title === 'string' && CANCELED_TITLE.test(row.title)
24
+ }
25
+
26
+ /** The identity of one meeting: its title and its start instant. */
27
+ export function meetingKey(row: MeetingFields): string {
28
+ const title = typeof row.title === 'string' ? row.title.trim().toLowerCase().replace(/\s+/g, ' ') : ''
29
+ const start = typeof row.start === 'number' ? row.start : Date.parse(String(row.start ?? ''))
30
+ return `${title}|${Number.isFinite(start) ? start : String(row.start ?? '')}`
31
+ }
32
+
33
+ /** The rows that are real meetings, one per meeting, in the order given. */
34
+ export function distinctMeetings<T>(rows: readonly T[], fields: (row: T) => MeetingFields): T[] {
35
+ const seen = new Set<string>()
36
+ const out: T[] = []
37
+ for (const row of rows) {
38
+ const f = fields(row)
39
+ if (isCanceledMeeting(f)) continue
40
+ const key = meetingKey(f)
41
+ if (seen.has(key)) continue
42
+ seen.add(key)
43
+ out.push(row)
44
+ }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * Prep on every Next up row (Miles typed 2026-11-09 13:00 CST): a calendar hold or status block is not a meeting to
50
+ * prep for. ONE list for the server: /api/calendar/today marks each row `hold`, /api/welcome-context marks nextEvent
51
+ * `hold`, and Control Web and desktop Control read that flag instead of keeping their own copy of the words.
52
+ *
53
+ * A title is a hold when, after leading punctuation ("[Hold] ...", "* Busy"), it starts with one of these words or
54
+ * phrases as a whole word, and it does not name someone with "with" ("Lunch with Jeremy" and "Focus group with the
55
+ * Jewel360 board" are meetings). Case and repeated spaces do not matter. A false "not a hold" only offers Prep on a hold;
56
+ * a false "hold" would hide Prep on a real meeting, so the rule stays narrow.
57
+ */
58
+ export const CALENDAR_HOLD_WORDS: readonly string[] = Object.freeze([
59
+ 'blocked', 'block', 'busy', 'hold', 'holds', 'focus', 'travel', 'personal', 'private', 'ooo', 'out of office', 'lunch',
60
+ 'pto', 'vacation', 'commute', 'dnd', 'do not book', 'do not schedule',
61
+ ])
62
+ // Letters and single spaces only, so the words need no escaping.
63
+ const HOLD_START = new RegExp(`^(?:${CALENDAR_HOLD_WORDS.join('|')})(?![a-z0-9])`)
64
+
65
+ export function isCalendarHold(title: unknown): boolean {
66
+ if (typeof title !== 'string') return false
67
+ const t = title.trim().toLowerCase().replace(/\s+/g, ' ').replace(/^[^a-z0-9]+/, '')
68
+ if (!t || !HOLD_START.test(t)) return false
69
+ return !/(?:^|[^a-z0-9])with(?![a-z0-9])/.test(t)
70
+ }
@@ -22,6 +22,7 @@
22
22
  import { lstatSync, readFileSync } from 'node:fs'
23
23
  import { dataPath } from './data-dir.js'
24
24
  import { callPython, pythonBridgeAvailable } from './python-bridge.js'
25
+ import { distinctMeetings, isCalendarHold, type MeetingFields } from './calendar-meetings.js'
25
26
 
26
27
  export const SNAPSHOT_FRESH_MS = 2 * 3600_000
27
28
  export const SNAPSHOT_MAX_AGE_MS = 24 * 3600_000
@@ -61,6 +62,8 @@ export interface SnapshotEvent {
61
62
  attendees: SnapshotAttendee[]
62
63
  /** Allowlisted hosts only (zoom.us, teams.microsoft.com, meet.google.com). */
63
64
  meetingUrl: string | null
65
+ /** Today round 2: EventKit's status said canceled (only when the writer sends `status: "canceled"`). */
66
+ canceled?: boolean
64
67
  }
65
68
 
66
69
  export interface CalendarSnapshot {
@@ -159,6 +162,7 @@ export function parseSnapshot(raw: unknown): CalendarSnapshot | null {
159
162
  isOrganizer: r.isOrganizer === true,
160
163
  attendees,
161
164
  meetingUrl: url,
165
+ ...(typeof r.status === 'string' && /^cancel+ed$/i.test(r.status.trim()) ? { canceled: true } : {}),
162
166
  })
163
167
  }
164
168
  return { version: 1, writtenAt, windowStart, windowEnd, access, calendars, events }
@@ -340,6 +344,7 @@ export function bridgeEvent(e: SnapshotEvent, calendar: SnapshotCalendar | undef
340
344
  event_id: e.eventId,
341
345
  external_id: e.externalId,
342
346
  occurrence_start: e.occurrenceStart,
347
+ ...(e.canceled ? { canceled: true } : {}),
343
348
  }
344
349
  }
345
350
 
@@ -454,32 +459,52 @@ export function countsAsMeeting(row: Record<string, unknown> | null | undefined)
454
459
  }
455
460
 
456
461
  /**
457
- * Today for Control Web and desktop: selected calendars only, the next 3 timed meetings that count. QA 7.0.0: all-day
458
- * and non-prep events (declined, holidays, attendee-less shared events) are filtered out BEFORE the 3 are taken, so they
459
- * never use up the slots Today's "Later" shows. QA W23: remainingCount counts the same rows, so a declined meeting or
460
- * a family flight is not "remaining".
462
+ * Today for Control Web and desktop: selected calendars only, the next 5 timed meetings that count (Today round 2,
463
+ * Miles 2026-11-09: was 3). QA 7.0.0: all-day and non-prep events (declined, holidays, attendee-less shared events) are
464
+ * filtered out BEFORE the 5 are taken, so they never use up the slots Today's "Later" shows. QA W23: remainingCount
465
+ * counts the same rows, so a declined meeting or a family flight is not "remaining".
466
+ *
467
+ * Today round 2: `endedRecently` lists the meetings that count which ended within the last CALENDAR_ENDED_RECENT_MS
468
+ * (2 h), newest end first, at most CALENDAR_ENDED_MAX. They come from `today_events` (the snapshot's and the bridge's
469
+ * own list of every event today, past ones included), with the same end rule the snapshot uses (no end: an hour after
470
+ * the start). The Today summary uses it to know a meeting is over, so its prep is past. Additive: the older fields keep
471
+ * their names and meaning (desktop Control 0.5.384 reads next, remainingCount, stale and asOf).
472
+ *
473
+ * Today round 2 follow-up: canceled events and duplicates (the same title at the same start) are dropped BEFORE next,
474
+ * remainingCount and endedRecently are taken (calendar-meetings.ts, the helper the Today summary uses too), so
475
+ * remainingCount counts real, distinct meetings and no slot is spent twice on one.
461
476
  */
462
- export async function calendarToday(now = Date.now()): Promise<{
477
+ export const CALENDAR_NEXT_MAX = 5
478
+ export const CALENDAR_ENDED_RECENT_MS = 2 * 3600_000
479
+ export const CALENDAR_ENDED_MAX = 5
480
+ /** `hold` (Prep on every Next up row, 11/09): a hold or status block by its title (calendar-meetings.ts isCalendarHold). */
481
+ export interface CalendarTodayRow { title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null; hold: boolean }
482
+ export interface CalendarEndedRow { title: string; start: string; end: string }
483
+ export interface CalendarTodayView {
463
484
  connected: boolean
464
485
  source: CalendarSourceName
465
486
  asOf: string | null
466
487
  stale: boolean
467
488
  remainingCount: number
468
- next: Array<{ title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null }>
469
- }> {
489
+ next: CalendarTodayRow[]
490
+ endedRecently: CalendarEndedRow[]
491
+ }
492
+ export async function calendarToday(now = Date.now()): Promise<CalendarTodayView> {
470
493
  const read = await readCalendar(8_000, now)
471
494
  const data = read.data as Record<string, unknown> | null
472
495
  const connected = !!data && typeof data === 'object' && !('error' in data) && (read.source !== 'bridge' || pythonBridgeAvailable())
473
496
  const rows = connected && Array.isArray(data!.today_remaining) ? data!.today_remaining as Array<Record<string, unknown>> : []
474
- const counted = rows.filter(countsAsMeeting)
475
- const next = counted.slice(0, 3).map(r => ({
497
+ const counted = distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)
498
+ const next = counted.slice(0, CALENDAR_NEXT_MAX).map(r => ({
476
499
  title: typeof r.title === 'string' ? r.title : 'Event',
477
500
  start: String(r.start_time ?? ''),
478
501
  end: typeof r.end_time === 'string' ? r.end_time : null,
479
502
  allDay: r.is_all_day === true,
480
503
  calendar: typeof r.calendar_name === 'string' ? r.calendar_name : null,
481
504
  meetingUrl: typeof r.meeting_url === 'string' ? r.meeting_url : null,
505
+ hold: isCalendarHold(r.title),
482
506
  }))
507
+ const all = connected && Array.isArray(data!.today_events) ? data!.today_events as Array<Record<string, unknown>> : []
483
508
  return {
484
509
  connected,
485
510
  source: read.source,
@@ -487,5 +512,25 @@ export async function calendarToday(now = Date.now()): Promise<{
487
512
  stale: data?.is_stale === true,
488
513
  remainingCount: connected ? counted.length : 0,
489
514
  next,
515
+ endedRecently: endedRecently(all, now),
516
+ }
517
+ }
518
+
519
+ /** A bridge-shaped row's meeting identity, for calendar-meetings.ts. */
520
+ export function bridgeMeetingFields(r: Record<string, unknown>): MeetingFields {
521
+ return { title: r.title, start: r.start_time, status: r.status, canceled: r.canceled }
522
+ }
523
+
524
+ /** Distinct, not canceled meetings that count and ended within CALENDAR_ENDED_RECENT_MS of `now`, newest end first. */
525
+ export function endedRecently(rows: Array<Record<string, unknown>>, now: number): CalendarEndedRow[] {
526
+ const out: Array<CalendarEndedRow & { endMs: number }> = []
527
+ for (const r of distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)) {
528
+ const startMs = Date.parse(String(r.start_time ?? ''))
529
+ if (!Number.isFinite(startMs)) continue
530
+ const endRaw = typeof r.end_time === 'string' && r.end_time ? Date.parse(r.end_time) : NaN
531
+ const endMs = Number.isFinite(endRaw) ? endRaw : startMs + 3600_000
532
+ if (endMs > now || now - endMs > CALENDAR_ENDED_RECENT_MS) continue
533
+ out.push({ title: typeof r.title === 'string' ? r.title : 'Event', start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(), endMs })
490
534
  }
535
+ return out.sort((a, b) => b.endMs - a.endMs).slice(0, CALENDAR_ENDED_MAX).map(({ endMs: _e, ...r }) => r)
491
536
  }
@@ -24,6 +24,7 @@ import { homedir } from 'node:os'
24
24
  import { dirname, join, resolve } from 'node:path'
25
25
  import { fileURLToPath } from 'node:url'
26
26
  import { atomicWriteFileSync } from './atomic-fs.js'
27
+ import { FULL_HOLD_HOOK_WAIT_S, NON_QUESTION_MATCHER, QUESTION_MATCHER, fullHoldSwitches } from './full-hold.js'
27
28
 
28
29
  const __dirname = dirname(fileURLToPath(import.meta.url))
29
30
 
@@ -187,6 +188,93 @@ function priorWaitBlock(scriptPath: string, sub: HookSubscription, paths: HookPa
187
188
  return canonicalBlock(scriptPath, { event: sub.event, async: sub.async, timeout: LEGACY_PERMISSION_HOOK_TIMEOUT_S }, paths)
188
189
  }
189
190
 
191
+ /**
192
+ * X1 (2026-11-09): how the PermissionRequest hook is written. With `COS_QUESTION_FULL_HOLD` and
193
+ * `COS_APPROVAL_FULL_HOLD` both off (the default) it is the ONE block every release since 6.60.0
194
+ * writes, byte for byte (`split: false`). With either on it is TWO blocks of ours, split by matcher
195
+ * (canary C9, both engines): `AskUserQuestion` waits `questionWaitS`, everything else `otherWaitS`;
196
+ * a kind whose switch is on waits FULL_HOLD_HOOK_WAIT_S, the other keeps 630. Each block passes its
197
+ * own wait to the script, so the stamp a request carries is its own block's. Reinstall hooks is what
198
+ * writes it: until then the file reads `drift` (`priorWaitOnly`, still halt-ready).
199
+ */
200
+ export interface PermissionHookPlan { split: boolean; questionWaitS: number; otherWaitS: number }
201
+
202
+ export function permissionHookPlan(env: NodeJS.ProcessEnv = process.env): PermissionHookPlan {
203
+ const on = fullHoldSwitches(env)
204
+ if (!on.questions && !on.approvals) return { split: false, questionWaitS: PERMISSION_HOOK_TIMEOUT_S, otherWaitS: PERMISSION_HOOK_TIMEOUT_S }
205
+ return {
206
+ split: true,
207
+ questionWaitS: on.questions ? FULL_HOLD_HOOK_WAIT_S : PERMISSION_HOOK_TIMEOUT_S,
208
+ otherWaitS: on.approvals ? FULL_HOLD_HOOK_WAIT_S : PERMISSION_HOOK_TIMEOUT_S,
209
+ }
210
+ }
211
+
212
+ const UNSPLIT_PLAN: PermissionHookPlan = { split: false, questionWaitS: PERMISSION_HOOK_TIMEOUT_S, otherWaitS: PERMISSION_HOOK_TIMEOUT_S }
213
+
214
+ /** The two blocks of a split PermissionRequest, question block first. */
215
+ function splitPermissionBlocks(scriptPath: string, sub: HookSubscription, paths: HookPaths, questionWaitS: number, otherWaitS: number): Record<string, unknown>[] {
216
+ return [
217
+ canonicalBlock(scriptPath, { ...sub, matcher: QUESTION_MATCHER, timeout: questionWaitS }, paths),
218
+ canonicalBlock(scriptPath, { ...sub, matcher: NON_QUESTION_MATCHER, timeout: otherWaitS }, paths),
219
+ ]
220
+ }
221
+
222
+ /** Our blocks for one event under a plan. Every event but a split PermissionRequest: exactly `[canonicalBlock]`. */
223
+ export function canonicalBlocks(scriptPath: string, sub: HookSubscription, paths: HookPaths, plan: PermissionHookPlan = UNSPLIT_PLAN): Record<string, unknown>[] {
224
+ if (sub.event === 'PermissionRequest' && plan.split) return splitPermissionBlocks(scriptPath, sub, paths, plan.questionWaitS, plan.otherWaitS)
225
+ return [canonicalBlock(scriptPath, sub, paths)]
226
+ }
227
+
228
+ /**
229
+ * Earlier shapes of our PermissionRequest blocks that differ from the plan only by the wait: the
230
+ * pre-6.60.0 block (`priorWaitBlock`); with a split plan, the one unsplit block; with an unsplit
231
+ * plan, every split pair this file can write (a flag turned off after Reinstall hooks).
232
+ */
233
+ function priorWaitVariants(scriptPath: string, sub: HookSubscription, paths: HookPaths, plan: PermissionHookPlan): Record<string, unknown>[][] {
234
+ const prior = priorWaitBlock(scriptPath, sub, paths)
235
+ if (!prior) return []
236
+ const out: Record<string, unknown>[][] = [[prior]]
237
+ if (plan.split) out.push([canonicalBlock(scriptPath, sub, paths)])
238
+ const waits = [PERMISSION_HOOK_TIMEOUT_S, FULL_HOLD_HOOK_WAIT_S]
239
+ for (const q of waits) for (const o of waits) {
240
+ if (plan.split && q === plan.questionWaitS && o === plan.otherWaitS) continue
241
+ if (q === PERMISSION_HOOK_TIMEOUT_S && o === PERMISSION_HOOK_TIMEOUT_S) continue
242
+ out.push(splitPermissionBlocks(scriptPath, sub, paths, q, o))
243
+ }
244
+ return out
245
+ }
246
+
247
+ /** Whether a hook block's `matcher` selects this tool, as Claude Code reads it: no matcher, `*` or an empty one is every tool, else a regex. */
248
+ function matcherSelects(matcher: unknown, toolName: string): boolean {
249
+ if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') return true
250
+ if (typeof matcher !== 'string') return false
251
+ if (matcher === toolName) return true
252
+ try { return new RegExp(`^(?:${matcher})$`).test(toolName) } catch { return false }
253
+ }
254
+
255
+ /**
256
+ * X1: how long Claude Code waits on our PermissionRequest hook FOR THIS TOOL: the smallest timeout
257
+ * among our blocks whose matcher selects it. With the one unsplit block, the same number as
258
+ * `permissionHookTimeoutS`. Null when none of ours selects it or one has no usable number.
259
+ */
260
+ export function permissionHookTimeoutForToolS(settings: unknown, toolName: string): number | null {
261
+ const hooks = settings && typeof settings === 'object' ? (settings as Record<string, unknown>).hooks : null
262
+ const blocks = hooks && typeof hooks === 'object' ? (hooks as Record<string, unknown>).PermissionRequest : null
263
+ if (!Array.isArray(blocks)) return null
264
+ let least: number | null = null
265
+ for (const block of blocks) {
266
+ if (!blockIsOurs(block)) continue
267
+ if (!matcherSelects((block as HookBlock).matcher, toolName)) continue
268
+ for (const h of (block as HookBlock).hooks as unknown[]) {
269
+ if (!hookIsOurs(h)) continue
270
+ const timeout = (h as { timeout?: unknown }).timeout
271
+ if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) return null
272
+ least = least === null ? timeout : Math.min(least, timeout)
273
+ }
274
+ }
275
+ return least
276
+ }
277
+
190
278
  /**
191
279
  * 6.60.0: how long Claude Code waits on OUR PermissionRequest hook, read from a settings
192
280
  * object: the smallest `timeout` among our entries (Claude runs each, so the first to give up
@@ -220,7 +308,7 @@ export type MergeResult =
220
308
  * `hooks` key that is not an object, or an event whose value is not an array, is refused
221
309
  * rather than replaced: "preserving every existing hook block" is the contract.
222
310
  */
223
- export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): MergeResult {
311
+ export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths(), plan: PermissionHookPlan = permissionHookPlan()): MergeResult {
224
312
  const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
225
313
  const before = JSON.stringify(settings)
226
314
  if (settings.hooks !== undefined && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return { ok: false, reason: 'settings_hooks_invalid' }
@@ -231,7 +319,7 @@ export function mergeHookSettings(current: unknown, scriptPath: string, paths: H
231
319
  if (value !== undefined && !Array.isArray(value)) return { ok: false, reason: 'settings_hooks_invalid' }
232
320
  const existing = Array.isArray(value) ? value : []
233
321
  const foreign = existing.filter(b => !blockIsOurs(b))
234
- hooks[sub.event] = [...foreign, canonicalBlock(scriptPath, sub, paths)]
322
+ hooks[sub.event] = [...foreign, ...canonicalBlocks(scriptPath, sub, paths, plan)]
235
323
  }
236
324
  settings.hooks = hooks
237
325
  return { ok: true, settings, changed: JSON.stringify(settings) !== before }
@@ -258,7 +346,7 @@ export function stripHookSettings(current: unknown): MergeResult {
258
346
  * (6.60.0) names the drifted events whose one block of ours is exactly the block an earlier
259
347
  * release wrote, differing only by the wait (`priorWaitBlock`).
260
348
  */
261
- export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[]; priorWait: string[] } {
349
+ export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths(), plan: PermissionHookPlan = permissionHookPlan()): { subscribed: string[]; missing: string[]; drifted: string[]; priorWait: string[] } {
262
350
  const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
263
351
  const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
264
352
  const subscribed: string[] = []
@@ -269,11 +357,10 @@ export function subscribedEvents(current: unknown, scriptPath: string, paths: Ho
269
357
  const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
270
358
  const ours = blocks.filter(blockIsOurs)
271
359
  if (ours.length === 0) { missing.push(sub.event); continue }
272
- const canonical = JSON.stringify(canonicalBlock(scriptPath, sub, paths))
273
- if (ours.length === 1 && JSON.stringify(ours[0]) === canonical) { subscribed.push(sub.event); continue }
360
+ const canonical = JSON.stringify(canonicalBlocks(scriptPath, sub, paths, plan))
361
+ if (JSON.stringify(ours) === canonical) { subscribed.push(sub.event); continue }
274
362
  drifted.push(sub.event)
275
- const prior = priorWaitBlock(scriptPath, sub, paths)
276
- if (prior && ours.length === 1 && JSON.stringify(ours[0]) === JSON.stringify(prior)) priorWait.push(sub.event)
363
+ if (priorWaitVariants(scriptPath, sub, paths, plan).some(variant => JSON.stringify(variant) === JSON.stringify(ours))) priorWait.push(sub.event)
277
364
  }
278
365
  return { subscribed, missing, drifted, priorWait }
279
366
  }
@@ -312,6 +399,11 @@ export interface HookStatus {
312
399
  observerOnlyDrift?: boolean
313
400
  /** 6.60.0: our PermissionRequest hook's `timeout` in the settings file, or null (`permissionHookTimeoutS`). */
314
401
  permissionHookTimeoutS: number | null
402
+ /**
403
+ * X1: the same, per kind (`permissionHookTimeoutForToolS` for AskUserQuestion and for any other
404
+ * tool). Equal to `permissionHookTimeoutS` while the block is not split.
405
+ */
406
+ permissionHookTimeouts?: { question: number | null; approval: number | null }
315
407
  tokenPresent: boolean
316
408
  }
317
409
 
@@ -343,7 +435,7 @@ function hooksDisabled(settings: unknown): boolean {
343
435
  return !!settings && typeof settings === 'object' && (settings as { disableAllHooks?: unknown }).disableAllHooks === true
344
436
  }
345
437
 
346
- export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths } = {}): HookStatus {
438
+ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths; plan?: PermissionHookPlan } = {}): HookStatus {
347
439
  const settingsPath = paths.settingsPath ?? claudeSettingsPath()
348
440
  const scriptPath = paths.scriptPath ?? stableHookScriptPath()
349
441
  const packageScriptPath = paths.packageScriptPath ?? packagedHookScriptPath()
@@ -355,8 +447,12 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
355
447
  if (!read.ok) {
356
448
  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 }
357
449
  }
358
- const events = subscribedEvents(read.settings, scriptPath, hookPaths)
450
+ const events = subscribedEvents(read.settings, scriptPath, hookPaths, paths.plan ?? permissionHookPlan())
359
451
  const permissionWaitS = permissionHookTimeoutS(read.settings)
452
+ const permissionHookTimeouts = {
453
+ question: permissionHookTimeoutForToolS(read.settings, 'AskUserQuestion'),
454
+ approval: permissionHookTimeoutForToolS(read.settings, 'Bash'),
455
+ }
360
456
  let state: HookInstallState
361
457
  if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
362
458
  else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
@@ -366,7 +462,7 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
366
462
  const priorWaitOnly = state === 'drift' && events.missing.length === 0 && events.drifted.length > 0
367
463
  && events.drifted.every(event => events.priorWait.includes(event))
368
464
  const observerOnlyDrift = state === 'drift' && events.missing.length > 0 && events.missing.every(event => event === 'PreCompact') && events.drifted.every(event => events.priorWait.includes(event))
369
- return { observerOnlyDrift, state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
465
+ return { observerOnlyDrift, state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, ...(paths.plan?.split || permissionHookPlan().split || permissionHookTimeouts.question !== permissionHookTimeouts.approval ? { permissionHookTimeouts } : {}), tokenPresent }
370
466
  }
371
467
 
372
468
  /**
@@ -476,6 +572,8 @@ export interface InstallOptions {
476
572
  port?: number
477
573
  deskIdleSeconds?: number
478
574
  dryRun?: boolean
575
+ /** X1: how the PermissionRequest hook is written. Default: from the two full-hold switches (`permissionHookPlan`). */
576
+ plan?: PermissionHookPlan
479
577
  }
480
578
 
481
579
  export interface InstallResult {
@@ -548,10 +646,11 @@ export function installClaudeHooks(options: InstallOptions = {}): InstallResult
548
646
  const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
549
647
  const hookPaths = options.hookPaths ?? currentHookPaths()
550
648
  const read = readSettings(settingsPath)
551
- const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths })
649
+ const plan = options.plan ?? permissionHookPlan()
650
+ const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths, plan })
552
651
  const refuse = (reason: string, scriptCopied = false): InstallResult => ({ ok: false, reason, changed: false, scriptCopied, backupPath: null, status: statusNow() })
553
652
  if (!read.ok) return refuse(read.reason)
554
- const merged = mergeHookSettings(read.settings, scriptPath, hookPaths)
653
+ const merged = mergeHookSettings(read.settings, scriptPath, hookPaths, plan)
555
654
  if (!merged.ok) return refuse(merged.reason)
556
655
  if (options.dryRun) {
557
656
  return { ok: true, changed: merged.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: merged.settings }
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
9
9
  import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
10
10
  import { appendToArchive, type SessionToArchive } from './archive.js'
11
11
  import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
12
- import { logSessionEnd, buildSessionLogEntry, writeSessionLog } from './session-log.js'
12
+ import { logSessionEnd, buildSessionLogEntry, writeSessionLogIfChanged, flushSessionLogState } from './session-log.js'
13
13
  import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
14
14
  import { localDay } from './local-day.js'
15
15
  import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
@@ -966,11 +966,14 @@ export async function endSession(sessionId: string): Promise<{ logged: boolean;
966
966
  return { logged: exchangeCount > 0, exchangeCount, durationMin }
967
967
  }
968
968
 
969
- /** Log all active sessions on server shutdown — prevents data loss on restart */
970
- export function logActiveSessionsOnShutdown(): void {
969
+ /** Log active sessions on server shutdown — prevents data loss on restart.
970
+ * S1 (2026-11-09): a session whose content was already written (by a snapshot or an
971
+ * earlier shutdown, in this boot or a previous one) is not appended again. */
972
+ export function logActiveSessionsOnShutdown(): { written: number; unchanged: number; unconfigured: number; failed: number } {
973
+ const counts = { written: 0, unchanged: 0, unconfigured: 0, failed: 0 }
971
974
  for (const session of sessions.values()) {
972
975
  if (session.exchanges.length === 0) continue
973
- logSessionEnd({
976
+ const entry = buildSessionLogEntry({
974
977
  id: session.id,
975
978
  exchanges: session.exchanges,
976
979
  createdAt: session.createdAt,
@@ -978,40 +981,65 @@ export function logActiveSessionsOnShutdown(): void {
978
981
  modelPreference: session.modelPreference,
979
982
  endReason: 'server_shutdown',
980
983
  })
984
+ counts[writeSessionLogIfChanged(entry, 'shutdown')]++
985
+ }
986
+ flushSessionLogState()
987
+ // No ledger configured (the new-install default): nothing to report.
988
+ if (counts.written || counts.unchanged || counts.failed) {
989
+ console.log(`[session-log] shutdown: ${counts.written} written, ${counts.unchanged} unchanged, ${counts.failed} failed`)
981
990
  }
991
+ return counts
982
992
  }
983
993
 
984
- // ── Auto-snapshot: periodically write active sessions to .glasses_sessions.jsonl ──
985
- // Runs every 5 minutes. No LLM calls, no network — just JSON serialize + file append.
986
- // Cost: ~0 CPU, ~0 RAM, ~50KB disk per snapshot.
994
+ // ── Auto-snapshot: periodically write changed active sessions to .glasses_sessions.jsonl ──
995
+ // Runs every 5 minutes. No LLM calls, no network. A session is appended only when its
996
+ // content changed since the last write for it, and that memory survives restarts
997
+ // (session-log.ts). Before 2026-11-09 the memory was this module's own map, empty after
998
+ // every boot, so the first tick after a restart re-appended every session (about 300 entries,
999
+ // roughly 5 MB, per boot on Miles's Mac; not the "~50KB per snapshot" once written here).
987
1000
  let snapshotTimer: ReturnType<typeof setInterval> | null = null
988
- let lastSnapshotCounts: Record<string, number> = {} // track exchange counts to skip unchanged sessions
1001
+ let lastSnapshotCounts: Record<string, number> = {} // cheap in-process pre-check before hashing
1002
+
1003
+ /** One auto-snapshot pass. Exported for tests. */
1004
+ export function runAutoSnapshotOnce(): { written: number; unchanged: number; unconfigured: number; failed: number } {
1005
+ const counts = { written: 0, unchanged: 0, unconfigured: 0, failed: 0 }
1006
+ for (const session of sessions.values()) {
1007
+ if (session.exchanges.length === 0) continue
1008
+ // Skip if exchange count hasn't changed since this process last looked
1009
+ const prevCount = lastSnapshotCounts[session.id]
1010
+ if (prevCount !== undefined && session.exchanges.length === prevCount) continue
1011
+
1012
+ const entry = buildSessionLogEntry({
1013
+ id: session.id,
1014
+ exchanges: session.exchanges,
1015
+ createdAt: session.createdAt,
1016
+ lastActivity: Date.now(), // Use NOW, not lastActivity — ensures lookup covers up to this moment
1017
+ modelPreference: session.modelPreference,
1018
+ endReason: 'explicit_end',
1019
+ slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
1020
+ })
1021
+ counts[writeSessionLogIfChanged(entry, 'snapshot')]++
1022
+ // Recorded whatever the result, as before S1: a failed or unconfigured write is not retried
1023
+ // every 5 minutes (Q1 review M2), only after the session changes again.
1024
+ lastSnapshotCounts[session.id] = session.exchanges.length
1025
+ }
1026
+ if (counts.written) flushSessionLogState()
1027
+ return counts
1028
+ }
989
1029
 
990
1030
  export function startAutoSnapshot(intervalMs = 5 * 60_000): void {
991
1031
  if (snapshotTimer) return
992
- snapshotTimer = setInterval(() => {
993
- for (const session of sessions.values()) {
994
- if (session.exchanges.length === 0) continue
995
- // Skip if exchange count hasn't changed since last snapshot
996
- const prevCount = lastSnapshotCounts[session.id] ?? 0
997
- if (session.exchanges.length === prevCount) continue
998
-
999
- const entry = buildSessionLogEntry({
1000
- id: session.id,
1001
- exchanges: session.exchanges,
1002
- createdAt: session.createdAt,
1003
- lastActivity: Date.now(), // Use NOW, not lastActivity — ensures lookup covers up to this moment
1004
- modelPreference: session.modelPreference,
1005
- endReason: 'explicit_end',
1006
- slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
1007
- })
1008
- writeSessionLog(entry)
1009
- lastSnapshotCounts[session.id] = session.exchanges.length
1010
- }
1011
- }, intervalMs)
1032
+ snapshotTimer = setInterval(() => { runAutoSnapshotOnce() }, intervalMs)
1012
1033
  console.log(`[conversation] Auto-snapshot started (every ${intervalMs / 1000}s)`)
1013
1034
  }
1014
1035
 
1036
+ /** Tests only. */
1037
+ export function __resetAutoSnapshotForTests(): void {
1038
+ if (snapshotTimer) clearInterval(snapshotTimer)
1039
+ snapshotTimer = null
1040
+ lastSnapshotCounts = {}
1041
+ }
1042
+
1015
1043
  /** Return all active sessions with exchanges, shaped for archiving */
1016
1044
  export function getActiveSessions(): SessionToArchive[] {
1017
1045
  const result: SessionToArchive[] = []
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Log a rejected CORS origin (S1, 2026-11-09).
3
+ *
4
+ * The scan of 10/26 to 11/09 found 22 "Error: CORS blocked" lines with nothing else, so no one
5
+ * could tell a COS client bug from a probe. This logs the ORIGIN only (an Origin header is a
6
+ * scheme, host and port; never a header dump, cookie or token), once per origin per hour, with a
7
+ * running count, and caps how many distinct origins it remembers so a scanner cannot grow it.
8
+ */
9
+ const ONE_HOUR_MS = 60 * 60_000
10
+ export const CORS_REJECT_ORIGIN_CAP = 200
11
+ const seen = new Map<string, { count: number; lastLoggedAt: number }>()
12
+ let overflow = 0
13
+
14
+ /** An origin made safe for one log line: printable ASCII, no spaces, at most 200 characters. */
15
+ export function sanitizeOrigin(origin: string): string {
16
+ const clean = origin.replace(/[^\x21-\x7e]/g, '?')
17
+ return clean.length > 200 ? `${clean.slice(0, 200)}…(${clean.length} chars)` : clean
18
+ }
19
+
20
+ export type CorsRejectReason = 'not_allowed' | 'check_failed'
21
+
22
+ export function noteCorsRejection(
23
+ origin: string,
24
+ reason: CorsRejectReason,
25
+ now = Date.now(),
26
+ log: (line: string) => void = line => console.warn(line),
27
+ ): boolean {
28
+ const key = sanitizeOrigin(origin)
29
+ let entry = seen.get(key)
30
+ if (!entry) {
31
+ if (seen.size >= CORS_REJECT_ORIGIN_CAP) {
32
+ overflow++
33
+ if (overflow === 1 || overflow % 100 === 0) {
34
+ log(`[cors] blocked ${overflow} request(s) from origins beyond the first ${CORS_REJECT_ORIGIN_CAP} distinct ones (not listed)`)
35
+ }
36
+ return false
37
+ }
38
+ entry = { count: 0, lastLoggedAt: 0 }
39
+ seen.set(key, entry)
40
+ }
41
+ entry.count++
42
+ if (entry.lastLoggedAt && now - entry.lastLoggedAt < ONE_HOUR_MS) return false
43
+ entry.lastLoggedAt = now
44
+ log(`[cors] blocked origin=${key} reason=${reason} (${entry.count} from this origin since boot)`)
45
+ return true
46
+ }
47
+
48
+ export function __resetCorsRejectLogForTests(): void {
49
+ seen.clear()
50
+ overflow = 0
51
+ }