@gotcos/glasses-server 7.0.7 → 7.0.18

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 (87) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +170 -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 +126 -23
  8. package/server/lib/agent-session-recent.ts +321 -0
  9. package/server/lib/agent-session-store.ts +3 -3
  10. package/server/lib/api-auth.ts +37 -1
  11. package/server/lib/calendar-meetings.ts +70 -0
  12. package/server/lib/calendar-source.ts +54 -9
  13. package/server/lib/claude-hooks-installer.ts +111 -12
  14. package/server/lib/conversation.ts +56 -28
  15. package/server/lib/cors-reject-log.ts +51 -0
  16. package/server/lib/disk-io-meter.ts +202 -0
  17. package/server/lib/drop-service.ts +47 -12
  18. package/server/lib/drop-store.ts +21 -1
  19. package/server/lib/event-loop-stall.ts +62 -0
  20. package/server/lib/full-hold-engine.ts +114 -0
  21. package/server/lib/full-hold.ts +124 -0
  22. package/server/lib/g2-turn-provenance.ts +128 -9
  23. package/server/lib/listener-startup.ts +18 -0
  24. package/server/lib/meeting-batch-transcribe.ts +32 -5
  25. package/server/lib/permission-broker.ts +443 -11
  26. package/server/lib/secret-path-guard.ts +4 -1
  27. package/server/lib/session-cache-writer.ts +8 -1
  28. package/server/lib/session-hook-events.ts +23 -1
  29. package/server/lib/session-hooks-runtime.ts +34 -0
  30. package/server/lib/session-log.ts +313 -4
  31. package/server/lib/session-state-derive.ts +36 -0
  32. package/server/lib/session-stream-events.ts +25 -1
  33. package/server/lib/today-brief.ts +522 -58
  34. package/server/lib/turn-end-ask.ts +320 -0
  35. package/server/lib/turn-footer.ts +3 -3
  36. package/server/lib/web-control-integrity.ts +160 -0
  37. package/server/lib/web-control-offnet-page.ts +58 -0
  38. package/server/lib/web-control-static.ts +53 -9
  39. package/server/lib/web-key-authority.ts +372 -0
  40. package/server/lib/web-key-envelope.ts +98 -0
  41. package/server/lib/web-key-handoff.ts +135 -0
  42. package/server/lib/web-key-origin.ts +21 -0
  43. package/server/lib/web-key-passkey.ts +155 -0
  44. package/server/lib/web-key-registry.ts +825 -0
  45. package/server/lib/web-key-signer-app.ts +152 -0
  46. package/server/lib/web-session-auth.ts +133 -15
  47. package/server/lib/whisper-local.ts +93 -7
  48. package/server/lib/whisper-preview.ts +5 -3
  49. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  50. package/server/lib/work-review-runtime.ts +17 -4
  51. package/server/routes/agent-session-bindings.ts +16 -1
  52. package/server/routes/agent-session-stream.ts +7 -0
  53. package/server/routes/drops.ts +7 -2
  54. package/server/routes/g2-turns.ts +35 -0
  55. package/server/routes/health.ts +11 -1
  56. package/server/routes/live-events.ts +6 -1
  57. package/server/routes/permission-broker.ts +26 -0
  58. package/server/routes/query-jobs.ts +3 -0
  59. package/server/routes/sessions.ts +15 -24
  60. package/server/routes/thread-turn-queue.ts +16 -9
  61. package/server/routes/today-brief.ts +91 -36
  62. package/server/routes/web-control.ts +37 -4
  63. package/server/routes/web-key.ts +284 -0
  64. package/server/routes/welcome-context.ts +6 -2
  65. package/server/routes/work-orchestrator.ts +12 -7
  66. package/server/scripts/write-web-control-manifest.mjs +43 -0
  67. package/server/web-control/.manifest.json +44 -0
  68. package/server/web-control/app.css +36 -0
  69. package/server/web-control/app.js +953 -57
  70. package/server/web-control/apple-touch-icon.png +0 -0
  71. package/server/web-control/index.html +14 -1
  72. package/server/web-control/panes/cos-chat.css +52 -6
  73. package/server/web-control/panes/cos-chat.js +364 -32
  74. package/server/web-control/panes/questions.js +135 -14
  75. package/server/web-control/panes/settings.js +1 -1
  76. package/server/web-control/panes/today-actions.css +35 -0
  77. package/server/web-control/panes/today-actions.js +75 -6
  78. package/server/web-control/panes/work.css +3 -0
  79. package/server/web-control/panes/work.js +178 -35
  80. package/server/web-signer/.manifest.json +11 -0
  81. package/server/web-signer/index.html +74 -0
  82. package/server/web-signer/signer-proof.js +348 -0
  83. package/server/web-signer/signer-store.js +39 -0
  84. package/server/web-signer/signer.css +23 -0
  85. package/server/web-signer/signer.js +341 -0
  86. package/shared/web-approval-packets.golden.json +124 -0
  87. package/shared/web-turn-envelope.ts +271 -0
@@ -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
+ }
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Opt-in per-path disk I/O meter (S1, 2026-11-09). COS_DISK_IO_METER=1 turns it on; default off.
3
+ *
4
+ * Why: macOS filed 12 "disk writes" resource reports against the server's node process
5
+ * (2.1 to 8.6 GB over 0.5 to 6 h) with JavaScript frames unsymbolicated, so no report could
6
+ * name a file. Measured from outside on 11/09 (proc_pid_rusage, idle, no meeting): 111.7 KB/s
7
+ * written and about 1.8 to 5 MB/s read. The largest writer (work-reviews/reviews.json) is fixed;
8
+ * the rest could not be attributed without root. With the meter on, the server logs once an
9
+ * hour which paths took the bytes:
10
+ *
11
+ * [disk-io] 60 min: wrote 152.3 MB in 1,204 writes, read 3.1 GB in 9,880 reads; top writes: ...
12
+ *
13
+ * How: wraps the synchronous and promise write/read entry points of node:fs and
14
+ * node:fs/promises, then calls syncBuiltinESMExports() so modules that already imported
15
+ * named bindings see the wrappers. Each wrapper calls the original and only counts bytes;
16
+ * errors and return values pass through unchanged. Streams (createWriteStream) are not
17
+ * counted. Paths are folded so ids and temp suffixes group: "~/.cos-glasses/data/active-sessions/*.json".
18
+ */
19
+ import fs from 'node:fs'
20
+ import fsPromises from 'node:fs/promises'
21
+ import { homedir } from 'node:os'
22
+ import { syncBuiltinESMExports } from 'node:module'
23
+
24
+ interface Tally { bytes: number; ops: number }
25
+ interface Window { startedAt: number; writes: Map<string, Tally>; reads: Map<string, Tally> }
26
+
27
+ let windowState: Window | null = null
28
+ let restore: (() => void) | null = null
29
+ let reportTimer: ReturnType<typeof setInterval> | null = null
30
+ const fdPaths = new Map<number, string>()
31
+ /** Node's own fs functions call each other (appendFileSync calls writeFileSync, which calls
32
+ * openSync and writeSync). Only the outermost wrapped call counts. */
33
+ let depth = 0
34
+ function nested<T>(run: () => T): T {
35
+ depth++
36
+ try { return run() } finally { depth-- }
37
+ }
38
+
39
+ const HOME = homedir()
40
+
41
+ /** Group a path: home as ~, temp suffixes dropped, id-like segments as *. */
42
+ export function foldPath(path: string): string {
43
+ let p = path.startsWith(HOME) ? `~${path.slice(HOME.length)}` : path
44
+ p = p.replace(/(\.\d+\.[0-9a-f]+)?\.tmp$/i, '') // atomic-write temp names
45
+ p = p.replace(/\.corrupt-\d+$/, '.corrupt-*')
46
+ p = p.replace(/(^|[/._-])[0-9a-f]{8,}(?=$|[/._-])/gi, '$1*') // hashes, uuids, hex ids
47
+ p = p.replace(/(^|[/._-])\d{6,}(?=$|[/._-])/g, '$1*') // epoch ms, pids in names
48
+ p = p.replace(/meeting_\*_[a-z0-9]+/gi, 'meeting_*')
49
+ return p
50
+ }
51
+
52
+ function byteLength(data: unknown, encoding?: unknown): number {
53
+ if (typeof data === 'string') return Buffer.byteLength(data, typeof encoding === 'string' ? encoding as BufferEncoding : 'utf8')
54
+ if (data && typeof (data as ArrayBufferView).byteLength === 'number') return (data as ArrayBufferView).byteLength
55
+ return 0
56
+ }
57
+
58
+ function keyFor(target: unknown): string {
59
+ if (typeof target === 'number') return fdPaths.get(target) ?? `fd:${target}`
60
+ if (typeof target === 'string') return foldPath(target)
61
+ if (target instanceof URL) return foldPath(target.pathname)
62
+ if (Buffer.isBuffer(target)) return foldPath(target.toString())
63
+ if (target && typeof (target as { fd?: unknown }).fd === 'number') return fdPaths.get((target as { fd: number }).fd) ?? 'filehandle'
64
+ return 'unknown'
65
+ }
66
+
67
+ function count(kind: 'writes' | 'reads', target: unknown, bytes: number, outer: boolean): void {
68
+ if (!windowState || bytes <= 0 || !outer) return
69
+ const map = windowState[kind]
70
+ const key = keyFor(target)
71
+ const tally = map.get(key)
72
+ if (tally) { tally.bytes += bytes; tally.ops++ } else map.set(key, { bytes, ops: 1 })
73
+ }
74
+
75
+ export interface DiskIoReport {
76
+ windowMs: number
77
+ wroteBytes: number; writeOps: number
78
+ readBytes: number; readOps: number
79
+ topWrites: Array<{ path: string; bytes: number; ops: number }>
80
+ topReads: Array<{ path: string; bytes: number; ops: number }>
81
+ }
82
+
83
+ /** The current window's totals (diagnostics, tests). Null when the meter is off. */
84
+ export function diskIoSnapshot(top = 10): DiskIoReport | null {
85
+ if (!windowState) return null
86
+ const sum = (m: Map<string, Tally>) => [...m.values()].reduce((a, t) => ({ bytes: a.bytes + t.bytes, ops: a.ops + t.ops }), { bytes: 0, ops: 0 })
87
+ const rank = (m: Map<string, Tally>) => [...m.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, top)
88
+ .map(([path, t]) => ({ path, bytes: t.bytes, ops: t.ops }))
89
+ const w = sum(windowState.writes), r = sum(windowState.reads)
90
+ return {
91
+ windowMs: Date.now() - windowState.startedAt,
92
+ wroteBytes: w.bytes, writeOps: w.ops, readBytes: r.bytes, readOps: r.ops,
93
+ topWrites: rank(windowState.writes), topReads: rank(windowState.reads),
94
+ }
95
+ }
96
+
97
+ function mb(bytes: number): string {
98
+ return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${(bytes / 1e6).toFixed(1)} MB`
99
+ }
100
+
101
+ /** One log line for the window, then start a new window. */
102
+ export function reportDiskIo(log: (line: string) => void = line => console.log(line)): DiskIoReport | null {
103
+ const report = diskIoSnapshot(8)
104
+ if (!report || !windowState) return null
105
+ const fmt = (rows: DiskIoReport['topWrites']) => rows.map(r => `${r.path} ${mb(r.bytes)} (${r.ops})`).join('; ') || 'none'
106
+ log(`[disk-io] ${Math.round(report.windowMs / 60_000)} min: wrote ${mb(report.wroteBytes)} in ${report.writeOps} writes, `
107
+ + `read ${mb(report.readBytes)} in ${report.readOps} reads; top writes: ${fmt(report.topWrites)}; top reads: ${fmt(report.topReads)}`)
108
+ windowState = { startedAt: Date.now(), writes: new Map(), reads: new Map() }
109
+ return report
110
+ }
111
+
112
+ export function diskIoMeterRequested(env: NodeJS.ProcessEnv = process.env): boolean {
113
+ return env.COS_DISK_IO_METER === '1'
114
+ }
115
+
116
+ /** Install the wrappers. Idempotent. Returns false when already installed. */
117
+ export function installDiskIoMeter(options: { reportEveryMs?: number } = {}): boolean {
118
+ if (restore) return false
119
+ windowState = { startedAt: Date.now(), writes: new Map(), reads: new Map() }
120
+ const f = fs as unknown as Record<string, any>
121
+ const p = fsPromises as unknown as Record<string, any>
122
+ const saved: Array<[Record<string, any>, string, unknown]> = []
123
+ const wrap = (obj: Record<string, any>, name: string, make: (orig: any) => any) => {
124
+ const orig = obj[name]
125
+ if (typeof orig !== 'function') return
126
+ saved.push([obj, name, orig])
127
+ obj[name] = make(orig)
128
+ }
129
+ wrap(f, 'openSync', orig => function (this: unknown, path: unknown, ...rest: unknown[]) {
130
+ const fd = nested(() => orig.call(this, path, ...rest))
131
+ if (typeof fd === 'number' && (typeof path === 'string' || path instanceof URL)) fdPaths.set(fd, keyFor(path))
132
+ return fd
133
+ })
134
+ wrap(f, 'closeSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
135
+ fdPaths.delete(fd)
136
+ return orig.call(this, fd, ...rest)
137
+ })
138
+ wrap(f, 'writeFileSync', orig => function (this: unknown, target: unknown, data: unknown, options?: unknown) {
139
+ const outer = depth === 0
140
+ const result = nested(() => orig.call(this, target, data, options))
141
+ count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), outer)
142
+ return result
143
+ })
144
+ wrap(f, 'appendFileSync', orig => function (this: unknown, target: unknown, data: unknown, options?: unknown) {
145
+ const outer = depth === 0
146
+ const result = nested(() => orig.call(this, target, data, options))
147
+ count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), outer)
148
+ return result
149
+ })
150
+ wrap(f, 'writeSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
151
+ const outer = depth === 0
152
+ const written = nested(() => orig.call(this, fd, ...rest))
153
+ count('writes', fd, typeof written === 'number' ? written : 0, outer)
154
+ return written
155
+ })
156
+ wrap(f, 'readFileSync', orig => function (this: unknown, target: unknown, ...rest: unknown[]) {
157
+ const outer = depth === 0
158
+ const result = nested(() => orig.call(this, target, ...rest))
159
+ count('reads', target, byteLength(result), outer)
160
+ return result
161
+ })
162
+ wrap(f, 'readSync', orig => function (this: unknown, fd: number, ...rest: unknown[]) {
163
+ const outer = depth === 0
164
+ const read = nested(() => orig.call(this, fd, ...rest))
165
+ count('reads', fd, typeof read === 'number' ? read : 0, outer)
166
+ return read
167
+ })
168
+ for (const name of ['writeFile', 'appendFile'] as const) {
169
+ wrap(p, name, orig => async function (this: unknown, target: unknown, data: unknown, options?: unknown) {
170
+ const result = await orig.call(this, target, data, options)
171
+ count('writes', target, byteLength(data, typeof options === 'string' ? options : (options as { encoding?: unknown } | undefined)?.encoding), true)
172
+ return result
173
+ })
174
+ }
175
+ wrap(p, 'readFile', orig => async function (this: unknown, target: unknown, ...rest: unknown[]) {
176
+ const result = await orig.call(this, target, ...rest)
177
+ count('reads', target, byteLength(result), true)
178
+ return result
179
+ })
180
+ syncBuiltinESMExports()
181
+ restore = () => {
182
+ for (const [obj, name, orig] of saved) obj[name] = orig
183
+ syncBuiltinESMExports()
184
+ }
185
+ const every = options.reportEveryMs ?? 60 * 60_000
186
+ if (every > 0) {
187
+ reportTimer = setInterval(() => { reportDiskIo() }, every)
188
+ reportTimer.unref?.()
189
+ }
190
+ console.log(`[disk-io] meter on (COS_DISK_IO_METER=1): ${every > 0 ? `one line every ${Math.round(every / 60_000)} min` : 'reports on request only'}`)
191
+ return true
192
+ }
193
+
194
+ /** Remove the wrappers (tests, and a clean shutdown). */
195
+ export function uninstallDiskIoMeter(): void {
196
+ if (reportTimer) clearInterval(reportTimer)
197
+ reportTimer = null
198
+ restore?.()
199
+ restore = null
200
+ windowState = null
201
+ fdPaths.clear()
202
+ }