@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.
- package/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
package/server/lib/api-auth.ts
CHANGED
|
@@ -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
|
|
458
|
-
* and non-prep events (declined, holidays, attendee-less shared events) are
|
|
459
|
-
* never use up the slots Today's "Later" shows. QA W23: remainingCount
|
|
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
|
|
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:
|
|
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,
|
|
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,
|
|
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(
|
|
273
|
-
if (
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
970
|
-
|
|
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
|
-
|
|
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
|
|
986
|
-
//
|
|
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> = {} //
|
|
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
|
+
}
|