@gotcos/glasses-server 6.99.33 → 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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. 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 }
@@ -60,6 +60,8 @@ import {
60
60
  } from './claude-tool-access.js'
61
61
  import {
62
62
  classifyCodexError,
63
+ codexTerminalUsageLimitError,
64
+ safeCodexUsageLimitMessage,
63
65
  extractCodexThreadId,
64
66
  finishCodexRun,
65
67
  getCodexExecutionCwd,
@@ -382,6 +384,7 @@ export function runCodexKeyProfileTurn(
382
384
 
383
385
  function safeCodexUserError(message: string): string {
384
386
  const code = classifyCodexError(message)
387
+ if (code === 'provider_limit') return safeCodexUsageLimitMessage(message)
385
388
  if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
386
389
  if (code === 'codex.auth_error') return 'Codex auth failed. Run codex login on the Mac.'
387
390
  if (code === 'codex.timeout') return 'Codex timed out. Retry or start a new chat.'
@@ -1021,7 +1024,7 @@ export async function callCodexStreaming(
1021
1024
  rawProviderError,
1022
1025
  structuredProviderError,
1023
1026
  )
1024
- finalizeError(authenticationError ?? `codex-bridge: ${rawProviderError}`)
1027
+ finalizeError(authenticationError ?? codexTerminalUsageLimitError(event) ?? `codex-bridge: ${rawProviderError}`)
1025
1028
  }
1026
1029
  }
1027
1030
 
@@ -215,8 +215,37 @@ export function redactForCodexLedger(value: string, maxChars = ERROR_PREVIEW_CHA
215
215
  .slice(0, maxChars)
216
216
  }
217
217
 
218
+ /** Only a validated provider retry date may cross the terminal error boundary. */
219
+ export function safeCodexUsageLimitMessage(message: string): string {
220
+ const fallback = 'Codex usage limit reached. Try again later or choose another model.'
221
+ // Bound work on untrusted terminal text; do not preserve links, identifiers or prose.
222
+ const retry = /(?:try again|resets?) at (Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(20\d{2})\s+(\d{1,2}):(\d{2})\s*(AM|PM)\b/i.exec(message.slice(0, 8_000))
223
+ if (!retry) return fallback
224
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
225
+ const month = months.findIndex(value => value.toLowerCase() === retry[1].slice(0, 3).toLowerCase())
226
+ const day = Number(retry[2]), year = Number(retry[3]), hour = Number(retry[4]), minute = Number(retry[5])
227
+ const date = new Date(Date.UTC(year, month, day))
228
+ if (date.getUTCMonth() !== month || date.getUTCDate() !== day || hour < 1 || hour > 12 || minute > 59) return fallback
229
+ return `Codex usage limit reached. Try again at ${months[month]} ${day}, ${year} ${hour}:${retry[5]} ${retry[6].toUpperCase()} (provider time), or choose another model.`
230
+ }
231
+
232
+ /** Codex JSON terminal errors carry the quota code separately from their message. */
233
+ export function codexTerminalUsageLimitError(event: unknown): string | undefined {
234
+ if (!event || typeof event !== 'object') return undefined
235
+ const value = event as Record<string, unknown>
236
+ if (value.type !== 'error' && value.type !== 'turn.failed') return undefined
237
+ const error = value.error && typeof value.error === 'object' ? value.error as Record<string, unknown> : undefined
238
+ const code = error?.codex_error_info ?? value.codex_error_info
239
+ if (code !== 'usage_limit_exceeded') return undefined
240
+ const message = typeof error?.message === 'string' ? error.message
241
+ : typeof value.message === 'string' ? value.message
242
+ : typeof value.error === 'string' ? value.error : ''
243
+ return safeCodexUsageLimitMessage(message)
244
+ }
245
+
218
246
  export function classifyCodexError(message: string): string {
219
247
  const text = message.toLowerCase()
248
+ if (/\busage_limit_exceeded\b|^codex usage limit reached\./.test(text)) return 'provider_limit'
220
249
  if (/command not found|enoent|not found/.test(text)) return 'codex.cli_unavailable'
221
250
  if (/permission|denied|sandbox|read-only|operation not permitted/.test(text)) return 'codex.permission_denied'
222
251
  if (/auth|login|sign in|unauthorized|forbidden|token/.test(text)) return 'codex.auth_error'
@@ -2,6 +2,7 @@
2
2
  // Caches context for 60s to avoid hammering Python scripts every query
3
3
 
4
4
  import { callPython, COS_MODE } from './python-bridge.js'
5
+ import { readCalendar } from './calendar-source.js'
5
6
  import { getOwnerName } from './profile.js'
6
7
 
7
8
  interface ContextCache {
@@ -76,7 +77,7 @@ async function fetchLiveContext(): Promise<string> {
76
77
 
77
78
  // Fetch calendar and tasks in parallel
78
79
  const [calResult, taskResult] = await Promise.allSettled([
79
- callPython(['calendar']),
80
+ readCalendar().then(r => r.data),
80
81
  callPython(['tasks']),
81
82
  ])
82
83
 
@@ -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
+ }