@gotcos/glasses-server 7.0.7 → 7.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -188,9 +188,16 @@ function loadArchivedSessions(): SessionCacheEntry[] {
188
188
  return entries
189
189
  }
190
190
 
191
+ // S1 (2026-11-09): said once per process. COS Control's generated .env has no COS_SCRIPTS_DIR, so
192
+ // on a new install this ran after every exchange (5 s debounce) and printed each time.
193
+ let scriptsDirMissingNoted = false
194
+
191
195
  export function updateGlassesSessionCache(): void {
192
196
  if (!SCRIPTS_DIR) {
193
- console.warn('[session-cache] COS_SCRIPTS_DIR not set — skipping cache write')
197
+ if (!scriptsDirMissingNoted) {
198
+ scriptsDirMissingNoted = true
199
+ console.warn('[session-cache] COS_SCRIPTS_DIR not set — skipping cache writes (said once)')
200
+ }
194
201
  return
195
202
  }
196
203
 
@@ -13,6 +13,8 @@
13
13
  // interactive-only events (Notification, StopFailure, sub-agents, compaction, model switch)
14
14
  // whose shapes are still the documented ones until a Desktop recording lands.
15
15
 
16
+ import { questionRowText, waitingQuestionWordsEnabled } from './full-hold.js'
17
+ import { captureTurnAsk, turnEndAsksEnabled, WRAP_UP_TOOL } from './turn-end-ask.js'
16
18
  import { createHash } from 'node:crypto'
17
19
  import { profileIdForCodexPath } from './codex-homes.js'
18
20
  import { isKeyProfileId } from '../../shared/key-preference.js'
@@ -208,12 +210,31 @@ function canonicalJson(value: unknown): string {
208
210
  export function toolTarget(toolInput: unknown, max = 80): string {
209
211
  if (!toolInput || typeof toolInput !== 'object') return ''
210
212
  const i = toolInput as Record<string, unknown>
211
- const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description]
213
+ // X1: an AskUserQuestion carries only `questions`. With COS_WAITING_QUESTION_WORDS on (default ON, Miles
214
+ // 22:10) its target is its first question's text, control and format characters out, cut at `max` (80)
215
+ // below, so a row waiting on a question says what it asks. Never an option, a description or an answer.
216
+ // `0`: a question's target is '' exactly as in 7.0.13, and the words reach neither the ledger nor a row.
217
+ const firstQuestion = waitingQuestionWordsEnabled(process.env) && Array.isArray(i.questions) && i.questions.length > 0 && i.questions[0] && typeof i.questions[0] === 'object'
218
+ ? questionRowText((i.questions[0] as Record<string, unknown>).question)
219
+ : undefined
220
+ const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description, firstQuestion]
212
221
  .find(v => typeof v === 'string' && v.trim().length > 0)
213
222
  const text = typeof candidate === 'string' ? candidate.replace(/\s+/g, ' ').trim() : ''
214
223
  return text.length > max ? text.slice(0, max - 1) + '…' : text
215
224
  }
216
225
 
226
+ /**
227
+ * 7.0.18 (X1): a turn-end ask (lib/turn-end-ask.ts) kept in the ledger so a restart replays it. Only on a
228
+ * PostToolUse of `mcp__ccd_turn__wrap_up`, only with COS_TURN_END_ASKS on, and only the bounded capture:
229
+ * the ask (80), at most 2 reply labels (40) and `blocked`. Never `recap`, never `tool_response`. An
230
+ * additive key, so 7.0.17 replays the row and ignores it. `tool_target` stays as 7.0.17 computes it.
231
+ */
232
+ function turnAskProjection(event: HookEventName, toolName: string, toolInput: unknown): Record<string, unknown> {
233
+ if (event !== 'PostToolUse' || toolName !== WRAP_UP_TOOL || !turnEndAsksEnabled()) return {}
234
+ const capture = captureTurnAsk(toolInput, waitingQuestionWordsEnabled(process.env))
235
+ return capture ? { turn_ask: capture } : {}
236
+ }
237
+
217
238
  /** Text fields kept from a payload. Everything else is dropped before the ledger sees it. */
218
239
  export const HOOK_TEXT_MAX = 400
219
240
 
@@ -272,6 +293,7 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
272
293
  ...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
273
294
  ? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
274
295
  : {}),
296
+ ...turnAskProjection(event, toolName, payload.tool_input),
275
297
  }
276
298
  }
277
299
  case 'Notification':
@@ -21,6 +21,9 @@ import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionE
21
21
  import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
22
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
23
23
  import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
24
+ import { readHidIdleSeconds } from './permission-broker.js'
25
+ import { waitingQuestionWordsEnabled } from './full-hold.js'
26
+ import { TurnEndAskTracker, turnEndAsksEnabled, type TurnEndAskCounters } from './turn-end-ask.js'
24
27
 
25
28
  export function sessionHooksEnabled(): boolean {
26
29
  const raw = process.env.COS_SESSION_HOOKS
@@ -256,6 +259,8 @@ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: {
256
259
  let ledger: SessionHookLedger | null = null
257
260
  let ingester: SpoolIngester | null = null
258
261
  let replayed: { rows: number; applied: number } | null = null
262
+ /** 7.0.18 (X1): the turn-end ask tracker, only while COS_TURN_END_ASKS is on (read once at startup). */
263
+ let turnEndAskTracker: TurnEndAskTracker | null = null
259
264
  /** The two-scan memory: consecutive dead observations and when the first one was. */
260
265
  const deadById = new Map<string, { scans: number; since: number | null; at: number }>()
261
266
  const DEAD_MEMORY_MS = 60 * 60_000
@@ -271,6 +276,18 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
271
276
  ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
272
277
  const enabled = sessionHooksEnabled()
273
278
  const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
279
+ // 7.0.18 (X1): subscribed BEFORE the replay, so an ask asked before a restart is still known after it.
280
+ // Off (COS_TURN_END_ASKS=0): no tracker, no subscription, no desk reads, every row 7.0.17's.
281
+ turnEndAskTracker?.stop()
282
+ turnEndAskTracker = enabled && turnEndAsksEnabled() ? new TurnEndAskTracker({
283
+ now: () => Date.now(),
284
+ enabled: () => true,
285
+ words: () => waitingQuestionWordsEnabled(process.env),
286
+ readDeskIdleSeconds: readHidIdleSeconds,
287
+ deskIdleSeconds,
288
+ }) : null
289
+ const tracker = turnEndAskTracker
290
+ const unsubscribeTurnAsk = tracker ? sessionSignalStore.subscribe((signal, env, child) => tracker.observe(env, child, signal)) : () => {}
274
291
  const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
275
292
  if (!enabled) return
276
293
  sessionSignalStore.apply(env, child)
@@ -321,6 +338,8 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
321
338
  stop() {
322
339
  clearInterval(pruneTimer)
323
340
  unsubscribeHalt()
341
+ unsubscribeTurnAsk()
342
+ tracker?.stop()
324
343
  ingester?.stop()
325
344
  },
326
345
  }
@@ -472,8 +491,10 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
472
491
  prevDeadScans: prev?.scans ?? 0,
473
492
  prevDeadSince: prev?.since ?? null,
474
493
  attachedTurn: input.attachedTurn === true,
494
+ ...(turnEndAskTracker ? { turnEndAsk: turnEndAskTracker.visibleAsk(key, now) } : {}),
475
495
  })
476
496
  if (derived.agent_state === 'ended') {
497
+ turnEndAskTracker?.sessionRowEnded(key)
477
498
  for (const listener of rowEndedListeners) {
478
499
  try { listener(key) } catch { /* a listener never breaks a row */ }
479
500
  }
@@ -489,7 +510,20 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
489
510
  return derived
490
511
  }
491
512
 
513
+ /** 7.0.18 (X1): the turn-end ask counters for the authenticated stats, or null with the switch off. */
514
+ export function turnEndAskStats(): (TurnEndAskCounters & { tracked: number }) | null {
515
+ return turnEndAskTracker ? turnEndAskTracker.stats() : null
516
+ }
517
+
518
+ /** Tests only: the tracker the runtime would build, installed directly. */
519
+ export function __setTurnEndAskTrackerForTests(tracker: TurnEndAskTracker | null): void {
520
+ turnEndAskTracker?.stop()
521
+ turnEndAskTracker = tracker
522
+ }
523
+
492
524
  export function __resetSessionHooksForTests(): void {
525
+ turnEndAskTracker?.stop()
526
+ turnEndAskTracker = null
493
527
  rowEndedListeners.clear()
494
528
  sessionSignalStore.__resetForTests()
495
529
  deadById.clear()
@@ -2,9 +2,12 @@
2
2
  // so COS can query Glasses sessions by original UUID, date, domain, or content.
3
3
  // Fires on: TTL expiry, explicit /api/sessions/:id/end, server shutdown.
4
4
 
5
- import { appendFileSync, chmodSync, mkdirSync } from 'node:fs'
5
+ import { appendFileSync, chmodSync, closeSync, constants as fsConstants, createReadStream, fstatSync, mkdirSync, openSync, statSync } from 'node:fs'
6
+ import { createHash } from 'node:crypto'
6
7
  import { resolve, dirname } from 'node:path'
7
8
  import type { Exchange } from './conversation.js'
9
+ import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
10
+ import { dataPath } from './data-dir.js'
8
11
 
9
12
  // Resolve COS_SCRIPTS_DIR independently (same pattern as session-cache-writer)
10
13
  let SCRIPTS_DIR: string | null = null
@@ -132,7 +135,13 @@ export function buildSessionLogEntry(input: EndSessionInput): SessionLogEntry {
132
135
  }
133
136
  }
134
137
 
135
- /** Append a session log entry to .glasses_sessions.jsonl */
138
+ /** Where the ledger lives, or null when COS_SCRIPTS_DIR is not set. */
139
+ export function sessionLogPath(): string | null {
140
+ return SCRIPTS_DIR ? resolve(SCRIPTS_DIR, LOG_FILE_NAME) : null
141
+ }
142
+
143
+ /** Append a session log entry to .glasses_sessions.jsonl. Unconditional: callers that may
144
+ * repeat an unchanged session go through writeSessionLogIfChanged instead. */
136
145
  export function writeSessionLog(entry: SessionLogEntry): boolean {
137
146
  if (!SCRIPTS_DIR) {
138
147
  console.warn('[session-log] COS_SCRIPTS_DIR not set — skipping session log')
@@ -153,11 +162,311 @@ export function writeSessionLog(entry: SessionLogEntry): boolean {
153
162
  }
154
163
  }
155
164
 
156
- /** End a session: build entry and write to JSONL */
165
+ // ── Write-once-per-content ledger state (S1, 2026-11-09) ──────────────────────────────
166
+ // Before this, every server shutdown appended EVERY session with exchanges (about 300 on
167
+ // Miles's Mac, since sessions.json keeps all ages), and the first auto-snapshot after
168
+ // each boot appended them all again, because its "unchanged" check lived only in memory.
169
+ // 72 boots in 14 days grew the ledger to 1.94 GB: 118,221 entries for 588 sessions, one
170
+ // session copied 1,254 times. The state below survives restarts, so an unchanged session
171
+ // is never appended twice. It is keyed by the ledger path, so pointing COS_SCRIPTS_DIR
172
+ // elsewhere starts a fresh state instead of suppressing writes to the new file.
173
+
174
+ /** Why an entry is being written. A snapshot or a shutdown is a copy of a live session;
175
+ * an end (TTL, explicit end, clear) closes it. */
176
+ export type SessionLogKind = 'snapshot' | 'shutdown' | 'end'
177
+
178
+ interface LedgerStateRow { sig: string; kind: SessionLogKind; at: number }
179
+ /** `ino`: the ledger file the rows describe. A different or missing file (compaction, a restore, a
180
+ * deletion) means the rows no longer describe what is on disk, so they are dropped (Q1 review S5). */
181
+ interface LedgerState { version: 1; logPath: string; ino?: number; sessions: Record<string, LedgerStateRow> }
182
+
183
+ function ledgerInode(logPath: string): number | null {
184
+ try { return statSync(logPath).ino } catch { return null }
185
+ }
186
+
187
+ /** Most sessions the state remembers; the oldest by last write are dropped past this. */
188
+ export const SESSION_LOG_STATE_CAP = 5_000
189
+ const STATE_FILE_NAME = 'session-log-state.json'
190
+
191
+ let ledgerState: LedgerState | null = null
192
+ let ledgerStateDirty = false
193
+ let ledgerStateFlushTimer: ReturnType<typeof setTimeout> | null = null
194
+
195
+ function stateFilePath(): string {
196
+ return dataPath(STATE_FILE_NAME)
197
+ }
198
+
199
+ function loadLedgerState(logPath: string): LedgerState {
200
+ if (ledgerState && ledgerState.logPath === logPath) return ledgerState
201
+ const fresh: LedgerState = { version: 1, logPath, sessions: {} }
202
+ try {
203
+ const result = loadJsonOrQuarantine<LedgerState>(stateFilePath())
204
+ const loaded = result.status === 'ok' ? result.data : null
205
+ if (loaded && loaded.version === 1 && loaded.logPath === logPath
206
+ && loaded.sessions && typeof loaded.sessions === 'object') {
207
+ ledgerState = loaded
208
+ return loaded
209
+ }
210
+ } catch { /* unreadable state: start fresh; the worst case is one more copy per session */ }
211
+ ledgerState = fresh
212
+ return fresh
213
+ }
214
+
215
+ /** What makes two entries for one session the same: its messages, counts and model.
216
+ * Timestamps the writer invents (a snapshot's ended_at is "now") are not part of it. */
217
+ export function sessionLogSignature(entry: SessionLogEntry): string {
218
+ return createHash('sha256').update(JSON.stringify([
219
+ entry.total_message_count,
220
+ entry.user_message_count,
221
+ entry.assistant_message_count,
222
+ entry.model_preference,
223
+ entry.created_at,
224
+ entry.messages,
225
+ ])).digest('hex').slice(0, 32)
226
+ }
227
+
228
+ /** Persist the state now (atomic). Called at the end of a shutdown or snapshot pass. */
229
+ export function flushSessionLogState(): void {
230
+ if (ledgerStateFlushTimer) { clearTimeout(ledgerStateFlushTimer); ledgerStateFlushTimer = null }
231
+ if (!ledgerState || !ledgerStateDirty) return
232
+ const rows = Object.entries(ledgerState.sessions)
233
+ if (rows.length > SESSION_LOG_STATE_CAP) {
234
+ rows.sort((a, b) => b[1].at - a[1].at)
235
+ ledgerState.sessions = Object.fromEntries(rows.slice(0, SESSION_LOG_STATE_CAP))
236
+ }
237
+ try {
238
+ mkdirSync(dirname(stateFilePath()), { recursive: true, mode: 0o700 })
239
+ atomicWriteFileSync(stateFilePath(), JSON.stringify(ledgerState), { mode: 0o600 })
240
+ ledgerStateDirty = false
241
+ } catch (err) {
242
+ console.error('[session-log] Failed to save ledger state:', err)
243
+ }
244
+ }
245
+
246
+ function scheduleStateFlush(): void {
247
+ if (ledgerStateFlushTimer) return
248
+ ledgerStateFlushTimer = setTimeout(() => { ledgerStateFlushTimer = null; flushSessionLogState() }, 1_000)
249
+ ledgerStateFlushTimer.unref?.()
250
+ }
251
+
252
+ /** 'unconfigured': COS_SCRIPTS_DIR is not set, so there is no ledger. That is the default for
253
+ * every new install (COS Control's generated .env does not set it), not a failure. */
254
+ export type SessionLogWriteResult = 'written' | 'unchanged' | 'unconfigured' | 'failed'
255
+
256
+ let unconfiguredNoted = false
257
+ /** Say once per process that there is no ledger (Q1 review M2: the auto-snapshot printed it per
258
+ * session every 5 minutes on a new install). */
259
+ function noteUnconfigured(): void {
260
+ if (unconfiguredNoted) return
261
+ unconfiguredNoted = true
262
+ console.log('[session-log] COS_SCRIPTS_DIR not set: no .glasses_sessions.jsonl ledger on this install (said once)')
263
+ }
264
+
265
+ /** Append the entry unless this session's content was already written.
266
+ * - snapshot / shutdown: skipped when the last write for the session (any kind) had the same content.
267
+ * - end: skipped only when the last write was itself an end with the same content, so a real end
268
+ * always lands once even after a snapshot of the same content (its slug and end_reason differ). */
269
+ export function writeSessionLogIfChanged(entry: SessionLogEntry, kind: SessionLogKind): SessionLogWriteResult {
270
+ const logPath = sessionLogPath()
271
+ if (!logPath) { noteUnconfigured(); return 'unconfigured' }
272
+ const state = loadLedgerState(logPath)
273
+ const ino = ledgerInode(logPath)
274
+ if (state.ino !== undefined && state.ino !== ino) {
275
+ state.sessions = {}
276
+ state.ino = undefined
277
+ ledgerStateDirty = true
278
+ }
279
+ const sig = sessionLogSignature(entry)
280
+ const prev = state.sessions[entry.session_id]
281
+ if (prev && prev.sig === sig && (kind !== 'end' || prev.kind === 'end')) return 'unchanged'
282
+ if (!writeSessionLog(entry)) return 'failed'
283
+ state.ino = ledgerInode(logPath) ?? undefined
284
+ state.sessions[entry.session_id] = { sig, kind, at: Date.now() }
285
+ ledgerStateDirty = true
286
+ scheduleStateFlush()
287
+ return 'written'
288
+ }
289
+
290
+ function kindForReason(reason: EndSessionInput['endReason']): SessionLogKind {
291
+ return reason === 'server_shutdown' ? 'shutdown' : 'end'
292
+ }
293
+
294
+ /** End a session: build entry and write to JSONL (once per content; see writeSessionLogIfChanged). */
157
295
  export function logSessionEnd(input: EndSessionInput): SessionLogEntry | null {
158
296
  if (input.exchanges.length === 0) return null
159
297
 
160
298
  const entry = buildSessionLogEntry(input)
161
- writeSessionLog(entry)
299
+ writeSessionLogIfChanged(entry, kindForReason(input.endReason))
162
300
  return entry
163
301
  }
302
+
303
+ // ── Reader (S1, 2026-11-09) ───────────────────────────────────────────────────────────
304
+ // /api/sessions/lookup read the whole ledger with readFileSync(...).toString(). At 1.94 GB
305
+ // that throws (the string is longer than V8 allows), the catch treated it as "no log
306
+ // file", and every lookup silently answered from live sessions only, after blocking the
307
+ // event loop for the read. This reader streams and reads only the bytes appended since the
308
+ // last call. It keeps, per session, what the old loop kept: the FIRST line's range, plus the
309
+ // latest end any line reported. Which of the two a lookup uses is the route's old rule
310
+ // (lookupRangesFromHistory below), unchanged.
311
+
312
+ export interface SessionLogRange {
313
+ /** created_at of the session's first line in the file. Never widened (the old loop never moved it). */
314
+ start: number
315
+ /** ended_at of that first line. */
316
+ firstEnd: number
317
+ /** The latest ended_at of any line for the session. */
318
+ end: number
319
+ }
320
+
321
+ interface RangeIndex { logPath: string; ino: number; offset: number; ranges: Map<string, SessionLogRange> }
322
+ let rangeIndex: RangeIndex | null = null
323
+ let rangeIndexBuild: Promise<Map<string, SessionLogRange>> | null = null
324
+
325
+ const ENTRY_PREFIX = /^\{"session_id":"((?:[^"\\]|\\.)*)","device_id":"(?:[^"\\]|\\.)*","created_at":"([^"]*)","ended_at":"([^"]*)"/
326
+
327
+ /** The identity and time range of one ledger line, or null for a line that is not an entry. */
328
+ export function parseSessionLogLine(line: string): { sessionId: string; start: number; end: number } | null {
329
+ let sessionId: string | undefined
330
+ let createdAt: string | undefined
331
+ let endedAt: string | undefined
332
+ const prefix = ENTRY_PREFIX.exec(line.slice(0, 512))
333
+ if (prefix) {
334
+ try { sessionId = JSON.parse(`"${prefix[1]}"`) } catch { sessionId = undefined }
335
+ createdAt = prefix[2]
336
+ endedAt = prefix[3]
337
+ }
338
+ if (sessionId === undefined) {
339
+ try {
340
+ const d = JSON.parse(line)
341
+ if (d && typeof d.session_id === 'string') {
342
+ sessionId = d.session_id
343
+ createdAt = typeof d.created_at === 'string' ? d.created_at : undefined
344
+ endedAt = typeof d.ended_at === 'string' ? d.ended_at : undefined
345
+ }
346
+ } catch { return null }
347
+ }
348
+ if (!sessionId || !createdAt) return null
349
+ const start = new Date(createdAt).getTime()
350
+ if (!Number.isFinite(start)) return null
351
+ const endParsed = endedAt ? new Date(endedAt).getTime() : NaN
352
+ const end = Number.isFinite(endParsed) ? endParsed : start + 7_200_000
353
+ return { sessionId, start, end }
354
+ }
355
+
356
+ function mergeRange(ranges: Map<string, SessionLogRange>, id: string, start: number, end: number): void {
357
+ const existing = ranges.get(id)
358
+ if (!existing) { ranges.set(id, { start, firstEnd: end, end }); return }
359
+ if (end > existing.end) existing.end = end
360
+ }
361
+
362
+ /**
363
+ * The ranges /api/sessions/lookup matches against, in first-seen order: the route's rule from
364
+ * before S1, unchanged (Q1 review M1, 2026-11-09). A session's range starts at its first line.
365
+ * Its end may be widened by later lines ONLY when the session is not in memory. A session still
366
+ * in memory (restored from sessions.json, however old) keeps its first line's end, because every
367
+ * boot snapshot of it is stamped ended_at = now: widening would let an October session claim
368
+ * November timestamps, and the glasses companion stores the answer for good.
369
+ * Sessions in `live` are skipped (the route lists them first, extended to now).
370
+ */
371
+ export function lookupRangesFromHistory(
372
+ history: ReadonlyMap<string, SessionLogRange>,
373
+ inMemory: (sessionId: string) => boolean,
374
+ live: ReadonlySet<string> = new Set(),
375
+ ): Array<{ sid: string; start: number; end: number }> {
376
+ const out: Array<{ sid: string; start: number; end: number }> = []
377
+ for (const [sid, range] of history) {
378
+ if (live.has(sid)) continue
379
+ out.push({ sid, start: range.start, end: inMemory(sid) ? range.firstEnd : Math.max(range.firstEnd, range.end) })
380
+ }
381
+ return out
382
+ }
383
+
384
+ /** Index lines in [from, to) of the file into `ranges`. Returns the offset just past the
385
+ * last complete line, so a half-written last line is read again next time. */
386
+ async function indexBytes(fd: number, from: number, to: number, ranges: Map<string, SessionLogRange>): Promise<number> {
387
+ if (to <= from) return from
388
+ let offset = from
389
+ let carry: Buffer[] = []
390
+ let carryLen = 0
391
+ // Reads the descriptor readSessionLogRanges opened and checked (a regular file), never the path again.
392
+ const stream = createReadStream('', { fd, autoClose: false, start: from, end: to - 1, highWaterMark: 1 << 20 })
393
+ for await (const raw of stream) {
394
+ let chunk = raw as Buffer
395
+ let nl = chunk.indexOf(10)
396
+ while (nl !== -1) {
397
+ const piece = chunk.subarray(0, nl)
398
+ // Only the first 512 bytes are needed for the fast path; keep the whole line only
399
+ // while it is short or when the fast path fails (legacy key order).
400
+ const lineBuf = carryLen ? Buffer.concat([...carry, piece]) : piece
401
+ const lineLen = carryLen + piece.length
402
+ carry = []; carryLen = 0
403
+ const head = lineBuf.subarray(0, Math.min(lineBuf.length, 512)).toString('utf8')
404
+ let parsed = ENTRY_PREFIX.test(head) ? parseSessionLogLine(head) : null
405
+ if (!parsed && lineBuf.length <= 64 * 1024 * 1024) parsed = parseSessionLogLine(lineBuf.toString('utf8'))
406
+ if (parsed) mergeRange(ranges, parsed.sessionId, parsed.start, parsed.end)
407
+ offset += lineLen + 1
408
+ chunk = chunk.subarray(nl + 1)
409
+ nl = chunk.indexOf(10)
410
+ }
411
+ if (chunk.length) { carry.push(Buffer.from(chunk)); carryLen += chunk.length }
412
+ }
413
+ return offset
414
+ }
415
+
416
+ /** One time range per session from the ledger, read incrementally. A shrunk or replaced
417
+ * file (compaction) is re-read from the start. A missing file is an empty map. */
418
+ export async function readSessionLogRanges(path: string | null = sessionLogPath()): Promise<Map<string, SessionLogRange>> {
419
+ if (!path) return new Map()
420
+ if (rangeIndexBuild) return rangeIndexBuild
421
+ rangeIndexBuild = (async () => {
422
+ // O_NONBLOCK (hazard-invariants ratchet): openSync on a FIFO with no writer never returns, and it runs on
423
+ // the server's only JS thread. Not O_NOFOLLOW: the append writer follows a symlinked ledger, and the reader
424
+ // must read what the writer writes. fstat follows the link, so a link to a FIFO or folder is refused below.
425
+ const nonBlock = typeof fsConstants.O_NONBLOCK === 'number' ? fsConstants.O_NONBLOCK : 0
426
+ let fd: number
427
+ try {
428
+ fd = openSync(path, fsConstants.O_RDONLY | nonBlock)
429
+ } catch {
430
+ rangeIndex = null
431
+ return new Map<string, SessionLogRange>()
432
+ }
433
+ try {
434
+ const st = fstatSync(fd)
435
+ if (!st.isFile()) {
436
+ rangeIndex = null
437
+ return new Map<string, SessionLogRange>()
438
+ }
439
+ if (!rangeIndex || rangeIndex.logPath !== path || rangeIndex.ino !== st.ino || rangeIndex.offset > st.size) {
440
+ rangeIndex = { logPath: path, ino: st.ino, offset: 0, ranges: new Map() }
441
+ }
442
+ const index = rangeIndex
443
+ index.offset = await indexBytes(fd, index.offset, st.size, index.ranges)
444
+ return index.ranges
445
+ } finally {
446
+ closeSync(fd)
447
+ }
448
+ })()
449
+ try {
450
+ return await rangeIndexBuild
451
+ } finally {
452
+ rangeIndexBuild = null
453
+ }
454
+ }
455
+
456
+ /** Size of the ledger in bytes, or null when it does not exist (diagnostics). */
457
+ export function sessionLogSize(): number | null {
458
+ const path = sessionLogPath()
459
+ if (!path) return null
460
+ try { return statSync(path).size } catch { return null }
461
+ }
462
+
463
+ /** Tests only: forget the in-memory state and reader index. */
464
+ export function __resetSessionLogForTests(): void {
465
+ if (ledgerStateFlushTimer) clearTimeout(ledgerStateFlushTimer)
466
+ ledgerStateFlushTimer = null
467
+ ledgerState = null
468
+ ledgerStateDirty = false
469
+ unconfiguredNoted = false
470
+ rangeIndex = null
471
+ rangeIndexBuild = null
472
+ }