@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.
- package/.env.example +19 -0
- package/CHANGELOG.md +170 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +126 -23
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +3 -3
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +11 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +16 -9
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +36 -0
- package/server/web-control/app.js +953 -57
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +14 -1
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +75 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +178 -35
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -24,6 +24,7 @@ import { homedir } from 'node:os'
|
|
|
24
24
|
import { dirname, join, resolve } from 'node:path'
|
|
25
25
|
import { fileURLToPath } from 'node:url'
|
|
26
26
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
27
|
+
import { FULL_HOLD_HOOK_WAIT_S, NON_QUESTION_MATCHER, QUESTION_MATCHER, fullHoldSwitches } from './full-hold.js'
|
|
27
28
|
|
|
28
29
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
29
30
|
|
|
@@ -187,6 +188,93 @@ function priorWaitBlock(scriptPath: string, sub: HookSubscription, paths: HookPa
|
|
|
187
188
|
return canonicalBlock(scriptPath, { event: sub.event, async: sub.async, timeout: LEGACY_PERMISSION_HOOK_TIMEOUT_S }, paths)
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* X1 (2026-11-09): how the PermissionRequest hook is written. With `COS_QUESTION_FULL_HOLD` and
|
|
193
|
+
* `COS_APPROVAL_FULL_HOLD` both off (the default) it is the ONE block every release since 6.60.0
|
|
194
|
+
* writes, byte for byte (`split: false`). With either on it is TWO blocks of ours, split by matcher
|
|
195
|
+
* (canary C9, both engines): `AskUserQuestion` waits `questionWaitS`, everything else `otherWaitS`;
|
|
196
|
+
* a kind whose switch is on waits FULL_HOLD_HOOK_WAIT_S, the other keeps 630. Each block passes its
|
|
197
|
+
* own wait to the script, so the stamp a request carries is its own block's. Reinstall hooks is what
|
|
198
|
+
* writes it: until then the file reads `drift` (`priorWaitOnly`, still halt-ready).
|
|
199
|
+
*/
|
|
200
|
+
export interface PermissionHookPlan { split: boolean; questionWaitS: number; otherWaitS: number }
|
|
201
|
+
|
|
202
|
+
export function permissionHookPlan(env: NodeJS.ProcessEnv = process.env): PermissionHookPlan {
|
|
203
|
+
const on = fullHoldSwitches(env)
|
|
204
|
+
if (!on.questions && !on.approvals) return { split: false, questionWaitS: PERMISSION_HOOK_TIMEOUT_S, otherWaitS: PERMISSION_HOOK_TIMEOUT_S }
|
|
205
|
+
return {
|
|
206
|
+
split: true,
|
|
207
|
+
questionWaitS: on.questions ? FULL_HOLD_HOOK_WAIT_S : PERMISSION_HOOK_TIMEOUT_S,
|
|
208
|
+
otherWaitS: on.approvals ? FULL_HOLD_HOOK_WAIT_S : PERMISSION_HOOK_TIMEOUT_S,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const UNSPLIT_PLAN: PermissionHookPlan = { split: false, questionWaitS: PERMISSION_HOOK_TIMEOUT_S, otherWaitS: PERMISSION_HOOK_TIMEOUT_S }
|
|
213
|
+
|
|
214
|
+
/** The two blocks of a split PermissionRequest, question block first. */
|
|
215
|
+
function splitPermissionBlocks(scriptPath: string, sub: HookSubscription, paths: HookPaths, questionWaitS: number, otherWaitS: number): Record<string, unknown>[] {
|
|
216
|
+
return [
|
|
217
|
+
canonicalBlock(scriptPath, { ...sub, matcher: QUESTION_MATCHER, timeout: questionWaitS }, paths),
|
|
218
|
+
canonicalBlock(scriptPath, { ...sub, matcher: NON_QUESTION_MATCHER, timeout: otherWaitS }, paths),
|
|
219
|
+
]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Our blocks for one event under a plan. Every event but a split PermissionRequest: exactly `[canonicalBlock]`. */
|
|
223
|
+
export function canonicalBlocks(scriptPath: string, sub: HookSubscription, paths: HookPaths, plan: PermissionHookPlan = UNSPLIT_PLAN): Record<string, unknown>[] {
|
|
224
|
+
if (sub.event === 'PermissionRequest' && plan.split) return splitPermissionBlocks(scriptPath, sub, paths, plan.questionWaitS, plan.otherWaitS)
|
|
225
|
+
return [canonicalBlock(scriptPath, sub, paths)]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Earlier shapes of our PermissionRequest blocks that differ from the plan only by the wait: the
|
|
230
|
+
* pre-6.60.0 block (`priorWaitBlock`); with a split plan, the one unsplit block; with an unsplit
|
|
231
|
+
* plan, every split pair this file can write (a flag turned off after Reinstall hooks).
|
|
232
|
+
*/
|
|
233
|
+
function priorWaitVariants(scriptPath: string, sub: HookSubscription, paths: HookPaths, plan: PermissionHookPlan): Record<string, unknown>[][] {
|
|
234
|
+
const prior = priorWaitBlock(scriptPath, sub, paths)
|
|
235
|
+
if (!prior) return []
|
|
236
|
+
const out: Record<string, unknown>[][] = [[prior]]
|
|
237
|
+
if (plan.split) out.push([canonicalBlock(scriptPath, sub, paths)])
|
|
238
|
+
const waits = [PERMISSION_HOOK_TIMEOUT_S, FULL_HOLD_HOOK_WAIT_S]
|
|
239
|
+
for (const q of waits) for (const o of waits) {
|
|
240
|
+
if (plan.split && q === plan.questionWaitS && o === plan.otherWaitS) continue
|
|
241
|
+
if (q === PERMISSION_HOOK_TIMEOUT_S && o === PERMISSION_HOOK_TIMEOUT_S) continue
|
|
242
|
+
out.push(splitPermissionBlocks(scriptPath, sub, paths, q, o))
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether a hook block's `matcher` selects this tool, as Claude Code reads it: no matcher, `*` or an empty one is every tool, else a regex. */
|
|
248
|
+
function matcherSelects(matcher: unknown, toolName: string): boolean {
|
|
249
|
+
if (matcher === undefined || matcher === null || matcher === '' || matcher === '*') return true
|
|
250
|
+
if (typeof matcher !== 'string') return false
|
|
251
|
+
if (matcher === toolName) return true
|
|
252
|
+
try { return new RegExp(`^(?:${matcher})$`).test(toolName) } catch { return false }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* X1: how long Claude Code waits on our PermissionRequest hook FOR THIS TOOL: the smallest timeout
|
|
257
|
+
* among our blocks whose matcher selects it. With the one unsplit block, the same number as
|
|
258
|
+
* `permissionHookTimeoutS`. Null when none of ours selects it or one has no usable number.
|
|
259
|
+
*/
|
|
260
|
+
export function permissionHookTimeoutForToolS(settings: unknown, toolName: string): number | null {
|
|
261
|
+
const hooks = settings && typeof settings === 'object' ? (settings as Record<string, unknown>).hooks : null
|
|
262
|
+
const blocks = hooks && typeof hooks === 'object' ? (hooks as Record<string, unknown>).PermissionRequest : null
|
|
263
|
+
if (!Array.isArray(blocks)) return null
|
|
264
|
+
let least: number | null = null
|
|
265
|
+
for (const block of blocks) {
|
|
266
|
+
if (!blockIsOurs(block)) continue
|
|
267
|
+
if (!matcherSelects((block as HookBlock).matcher, toolName)) continue
|
|
268
|
+
for (const h of (block as HookBlock).hooks as unknown[]) {
|
|
269
|
+
if (!hookIsOurs(h)) continue
|
|
270
|
+
const timeout = (h as { timeout?: unknown }).timeout
|
|
271
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout <= 0) return null
|
|
272
|
+
least = least === null ? timeout : Math.min(least, timeout)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return least
|
|
276
|
+
}
|
|
277
|
+
|
|
190
278
|
/**
|
|
191
279
|
* 6.60.0: how long Claude Code waits on OUR PermissionRequest hook, read from a settings
|
|
192
280
|
* object: the smallest `timeout` among our entries (Claude runs each, so the first to give up
|
|
@@ -220,7 +308,7 @@ export type MergeResult =
|
|
|
220
308
|
* `hooks` key that is not an object, or an event whose value is not an array, is refused
|
|
221
309
|
* rather than replaced: "preserving every existing hook block" is the contract.
|
|
222
310
|
*/
|
|
223
|
-
export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): MergeResult {
|
|
311
|
+
export function mergeHookSettings(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths(), plan: PermissionHookPlan = permissionHookPlan()): MergeResult {
|
|
224
312
|
const settings: Record<string, unknown> = current && typeof current === 'object' && !Array.isArray(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
225
313
|
const before = JSON.stringify(settings)
|
|
226
314
|
if (settings.hooks !== undefined && (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks))) return { ok: false, reason: 'settings_hooks_invalid' }
|
|
@@ -231,7 +319,7 @@ export function mergeHookSettings(current: unknown, scriptPath: string, paths: H
|
|
|
231
319
|
if (value !== undefined && !Array.isArray(value)) return { ok: false, reason: 'settings_hooks_invalid' }
|
|
232
320
|
const existing = Array.isArray(value) ? value : []
|
|
233
321
|
const foreign = existing.filter(b => !blockIsOurs(b))
|
|
234
|
-
hooks[sub.event] = [...foreign,
|
|
322
|
+
hooks[sub.event] = [...foreign, ...canonicalBlocks(scriptPath, sub, paths, plan)]
|
|
235
323
|
}
|
|
236
324
|
settings.hooks = hooks
|
|
237
325
|
return { ok: true, settings, changed: JSON.stringify(settings) !== before }
|
|
@@ -258,7 +346,7 @@ export function stripHookSettings(current: unknown): MergeResult {
|
|
|
258
346
|
* (6.60.0) names the drifted events whose one block of ours is exactly the block an earlier
|
|
259
347
|
* release wrote, differing only by the wait (`priorWaitBlock`).
|
|
260
348
|
*/
|
|
261
|
-
export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths()): { subscribed: string[]; missing: string[]; drifted: string[]; priorWait: string[] } {
|
|
349
|
+
export function subscribedEvents(current: unknown, scriptPath: string, paths: HookPaths = currentHookPaths(), plan: PermissionHookPlan = permissionHookPlan()): { subscribed: string[]; missing: string[]; drifted: string[]; priorWait: string[] } {
|
|
262
350
|
const hooks = current && typeof current === 'object' && (current as Record<string, unknown>).hooks
|
|
263
351
|
const table = hooks && typeof hooks === 'object' ? hooks as Record<string, unknown> : {}
|
|
264
352
|
const subscribed: string[] = []
|
|
@@ -269,11 +357,10 @@ export function subscribedEvents(current: unknown, scriptPath: string, paths: Ho
|
|
|
269
357
|
const blocks = Array.isArray(table[sub.event]) ? table[sub.event] as unknown[] : []
|
|
270
358
|
const ours = blocks.filter(blockIsOurs)
|
|
271
359
|
if (ours.length === 0) { missing.push(sub.event); continue }
|
|
272
|
-
const canonical = JSON.stringify(
|
|
273
|
-
if (
|
|
360
|
+
const canonical = JSON.stringify(canonicalBlocks(scriptPath, sub, paths, plan))
|
|
361
|
+
if (JSON.stringify(ours) === canonical) { subscribed.push(sub.event); continue }
|
|
274
362
|
drifted.push(sub.event)
|
|
275
|
-
|
|
276
|
-
if (prior && ours.length === 1 && JSON.stringify(ours[0]) === JSON.stringify(prior)) priorWait.push(sub.event)
|
|
363
|
+
if (priorWaitVariants(scriptPath, sub, paths, plan).some(variant => JSON.stringify(variant) === JSON.stringify(ours))) priorWait.push(sub.event)
|
|
277
364
|
}
|
|
278
365
|
return { subscribed, missing, drifted, priorWait }
|
|
279
366
|
}
|
|
@@ -312,6 +399,11 @@ export interface HookStatus {
|
|
|
312
399
|
observerOnlyDrift?: boolean
|
|
313
400
|
/** 6.60.0: our PermissionRequest hook's `timeout` in the settings file, or null (`permissionHookTimeoutS`). */
|
|
314
401
|
permissionHookTimeoutS: number | null
|
|
402
|
+
/**
|
|
403
|
+
* X1: the same, per kind (`permissionHookTimeoutForToolS` for AskUserQuestion and for any other
|
|
404
|
+
* tool). Equal to `permissionHookTimeoutS` while the block is not split.
|
|
405
|
+
*/
|
|
406
|
+
permissionHookTimeouts?: { question: number | null; approval: number | null }
|
|
315
407
|
tokenPresent: boolean
|
|
316
408
|
}
|
|
317
409
|
|
|
@@ -343,7 +435,7 @@ function hooksDisabled(settings: unknown): boolean {
|
|
|
343
435
|
return !!settings && typeof settings === 'object' && (settings as { disableAllHooks?: unknown }).disableAllHooks === true
|
|
344
436
|
}
|
|
345
437
|
|
|
346
|
-
export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths } = {}): HookStatus {
|
|
438
|
+
export function hookStatus(paths: { settingsPath?: string; scriptPath?: string; packageScriptPath?: string; hookPaths?: HookPaths; plan?: PermissionHookPlan } = {}): HookStatus {
|
|
347
439
|
const settingsPath = paths.settingsPath ?? claudeSettingsPath()
|
|
348
440
|
const scriptPath = paths.scriptPath ?? stableHookScriptPath()
|
|
349
441
|
const packageScriptPath = paths.packageScriptPath ?? packagedHookScriptPath()
|
|
@@ -355,8 +447,12 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
|
|
|
355
447
|
if (!read.ok) {
|
|
356
448
|
return { state: read.reason, installed: false, settingsPath, scriptPath, scriptSha, packageScriptSha, subscribed: [], missing: HOOK_SUBSCRIPTIONS.map(s => s.event), drifted: [], priorWait: [], priorWaitOnly: false, permissionHookTimeoutS: null, tokenPresent }
|
|
357
449
|
}
|
|
358
|
-
const events = subscribedEvents(read.settings, scriptPath, hookPaths)
|
|
450
|
+
const events = subscribedEvents(read.settings, scriptPath, hookPaths, paths.plan ?? permissionHookPlan())
|
|
359
451
|
const permissionWaitS = permissionHookTimeoutS(read.settings)
|
|
452
|
+
const permissionHookTimeouts = {
|
|
453
|
+
question: permissionHookTimeoutForToolS(read.settings, 'AskUserQuestion'),
|
|
454
|
+
approval: permissionHookTimeoutForToolS(read.settings, 'Bash'),
|
|
455
|
+
}
|
|
360
456
|
let state: HookInstallState
|
|
361
457
|
if (hooksDisabled(read.settings)) state = 'disabled_by_settings'
|
|
362
458
|
else if (events.subscribed.length === 0 && events.drifted.length === 0) state = 'missing'
|
|
@@ -366,7 +462,7 @@ export function hookStatus(paths: { settingsPath?: string; scriptPath?: string;
|
|
|
366
462
|
const priorWaitOnly = state === 'drift' && events.missing.length === 0 && events.drifted.length > 0
|
|
367
463
|
&& events.drifted.every(event => events.priorWait.includes(event))
|
|
368
464
|
const observerOnlyDrift = state === 'drift' && events.missing.length > 0 && events.missing.every(event => event === 'PreCompact') && events.drifted.every(event => events.priorWait.includes(event))
|
|
369
|
-
return { observerOnlyDrift, state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, tokenPresent }
|
|
465
|
+
return { observerOnlyDrift, state, installed: state === 'installed', settingsPath, scriptPath, scriptSha, packageScriptSha, ...events, priorWaitOnly, permissionHookTimeoutS: permissionWaitS, ...(paths.plan?.split || permissionHookPlan().split || permissionHookTimeouts.question !== permissionHookTimeouts.approval ? { permissionHookTimeouts } : {}), tokenPresent }
|
|
370
466
|
}
|
|
371
467
|
|
|
372
468
|
/**
|
|
@@ -476,6 +572,8 @@ export interface InstallOptions {
|
|
|
476
572
|
port?: number
|
|
477
573
|
deskIdleSeconds?: number
|
|
478
574
|
dryRun?: boolean
|
|
575
|
+
/** X1: how the PermissionRequest hook is written. Default: from the two full-hold switches (`permissionHookPlan`). */
|
|
576
|
+
plan?: PermissionHookPlan
|
|
479
577
|
}
|
|
480
578
|
|
|
481
579
|
export interface InstallResult {
|
|
@@ -548,10 +646,11 @@ export function installClaudeHooks(options: InstallOptions = {}): InstallResult
|
|
|
548
646
|
const packageScriptPath = options.packageScriptPath ?? packagedHookScriptPath()
|
|
549
647
|
const hookPaths = options.hookPaths ?? currentHookPaths()
|
|
550
648
|
const read = readSettings(settingsPath)
|
|
551
|
-
const
|
|
649
|
+
const plan = options.plan ?? permissionHookPlan()
|
|
650
|
+
const statusNow = () => hookStatus({ settingsPath, scriptPath, packageScriptPath, hookPaths, plan })
|
|
552
651
|
const refuse = (reason: string, scriptCopied = false): InstallResult => ({ ok: false, reason, changed: false, scriptCopied, backupPath: null, status: statusNow() })
|
|
553
652
|
if (!read.ok) return refuse(read.reason)
|
|
554
|
-
const merged = mergeHookSettings(read.settings, scriptPath, hookPaths)
|
|
653
|
+
const merged = mergeHookSettings(read.settings, scriptPath, hookPaths, plan)
|
|
555
654
|
if (!merged.ok) return refuse(merged.reason)
|
|
556
655
|
if (options.dryRun) {
|
|
557
656
|
return { ok: true, changed: merged.changed, scriptCopied: false, backupPath: null, status: statusNow(), merged: merged.settings }
|
|
@@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
9
9
|
import { notifySessionStart, notifySessionEnd } from './telegram-notify.js'
|
|
10
10
|
import { appendToArchive, type SessionToArchive } from './archive.js'
|
|
11
11
|
import { updateGlassesSessionCache, scheduleCacheUpdate, setSessionProvider } from './session-cache-writer.js'
|
|
12
|
-
import { logSessionEnd, buildSessionLogEntry,
|
|
12
|
+
import { logSessionEnd, buildSessionLogEntry, writeSessionLogIfChanged, flushSessionLogState } from './session-log.js'
|
|
13
13
|
import { atomicWriteFileSync, durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
14
14
|
import { localDay } from './local-day.js'
|
|
15
15
|
import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
@@ -966,11 +966,14 @@ export async function endSession(sessionId: string): Promise<{ logged: boolean;
|
|
|
966
966
|
return { logged: exchangeCount > 0, exchangeCount, durationMin }
|
|
967
967
|
}
|
|
968
968
|
|
|
969
|
-
/** Log
|
|
970
|
-
|
|
969
|
+
/** Log active sessions on server shutdown — prevents data loss on restart.
|
|
970
|
+
* S1 (2026-11-09): a session whose content was already written (by a snapshot or an
|
|
971
|
+
* earlier shutdown, in this boot or a previous one) is not appended again. */
|
|
972
|
+
export function logActiveSessionsOnShutdown(): { written: number; unchanged: number; unconfigured: number; failed: number } {
|
|
973
|
+
const counts = { written: 0, unchanged: 0, unconfigured: 0, failed: 0 }
|
|
971
974
|
for (const session of sessions.values()) {
|
|
972
975
|
if (session.exchanges.length === 0) continue
|
|
973
|
-
|
|
976
|
+
const entry = buildSessionLogEntry({
|
|
974
977
|
id: session.id,
|
|
975
978
|
exchanges: session.exchanges,
|
|
976
979
|
createdAt: session.createdAt,
|
|
@@ -978,40 +981,65 @@ export function logActiveSessionsOnShutdown(): void {
|
|
|
978
981
|
modelPreference: session.modelPreference,
|
|
979
982
|
endReason: 'server_shutdown',
|
|
980
983
|
})
|
|
984
|
+
counts[writeSessionLogIfChanged(entry, 'shutdown')]++
|
|
985
|
+
}
|
|
986
|
+
flushSessionLogState()
|
|
987
|
+
// No ledger configured (the new-install default): nothing to report.
|
|
988
|
+
if (counts.written || counts.unchanged || counts.failed) {
|
|
989
|
+
console.log(`[session-log] shutdown: ${counts.written} written, ${counts.unchanged} unchanged, ${counts.failed} failed`)
|
|
981
990
|
}
|
|
991
|
+
return counts
|
|
982
992
|
}
|
|
983
993
|
|
|
984
|
-
// ── Auto-snapshot: periodically write active sessions to .glasses_sessions.jsonl ──
|
|
985
|
-
// Runs every 5 minutes. No LLM calls, no network
|
|
986
|
-
//
|
|
994
|
+
// ── Auto-snapshot: periodically write changed active sessions to .glasses_sessions.jsonl ──
|
|
995
|
+
// Runs every 5 minutes. No LLM calls, no network. A session is appended only when its
|
|
996
|
+
// content changed since the last write for it, and that memory survives restarts
|
|
997
|
+
// (session-log.ts). Before 2026-11-09 the memory was this module's own map, empty after
|
|
998
|
+
// every boot, so the first tick after a restart re-appended every session (about 300 entries,
|
|
999
|
+
// roughly 5 MB, per boot on Miles's Mac; not the "~50KB per snapshot" once written here).
|
|
987
1000
|
let snapshotTimer: ReturnType<typeof setInterval> | null = null
|
|
988
|
-
let lastSnapshotCounts: Record<string, number> = {} //
|
|
1001
|
+
let lastSnapshotCounts: Record<string, number> = {} // cheap in-process pre-check before hashing
|
|
1002
|
+
|
|
1003
|
+
/** One auto-snapshot pass. Exported for tests. */
|
|
1004
|
+
export function runAutoSnapshotOnce(): { written: number; unchanged: number; unconfigured: number; failed: number } {
|
|
1005
|
+
const counts = { written: 0, unchanged: 0, unconfigured: 0, failed: 0 }
|
|
1006
|
+
for (const session of sessions.values()) {
|
|
1007
|
+
if (session.exchanges.length === 0) continue
|
|
1008
|
+
// Skip if exchange count hasn't changed since this process last looked
|
|
1009
|
+
const prevCount = lastSnapshotCounts[session.id]
|
|
1010
|
+
if (prevCount !== undefined && session.exchanges.length === prevCount) continue
|
|
1011
|
+
|
|
1012
|
+
const entry = buildSessionLogEntry({
|
|
1013
|
+
id: session.id,
|
|
1014
|
+
exchanges: session.exchanges,
|
|
1015
|
+
createdAt: session.createdAt,
|
|
1016
|
+
lastActivity: Date.now(), // Use NOW, not lastActivity — ensures lookup covers up to this moment
|
|
1017
|
+
modelPreference: session.modelPreference,
|
|
1018
|
+
endReason: 'explicit_end',
|
|
1019
|
+
slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
|
|
1020
|
+
})
|
|
1021
|
+
counts[writeSessionLogIfChanged(entry, 'snapshot')]++
|
|
1022
|
+
// Recorded whatever the result, as before S1: a failed or unconfigured write is not retried
|
|
1023
|
+
// every 5 minutes (Q1 review M2), only after the session changes again.
|
|
1024
|
+
lastSnapshotCounts[session.id] = session.exchanges.length
|
|
1025
|
+
}
|
|
1026
|
+
if (counts.written) flushSessionLogState()
|
|
1027
|
+
return counts
|
|
1028
|
+
}
|
|
989
1029
|
|
|
990
1030
|
export function startAutoSnapshot(intervalMs = 5 * 60_000): void {
|
|
991
1031
|
if (snapshotTimer) return
|
|
992
|
-
snapshotTimer = setInterval(() => {
|
|
993
|
-
for (const session of sessions.values()) {
|
|
994
|
-
if (session.exchanges.length === 0) continue
|
|
995
|
-
// Skip if exchange count hasn't changed since last snapshot
|
|
996
|
-
const prevCount = lastSnapshotCounts[session.id] ?? 0
|
|
997
|
-
if (session.exchanges.length === prevCount) continue
|
|
998
|
-
|
|
999
|
-
const entry = buildSessionLogEntry({
|
|
1000
|
-
id: session.id,
|
|
1001
|
-
exchanges: session.exchanges,
|
|
1002
|
-
createdAt: session.createdAt,
|
|
1003
|
-
lastActivity: Date.now(), // Use NOW, not lastActivity — ensures lookup covers up to this moment
|
|
1004
|
-
modelPreference: session.modelPreference,
|
|
1005
|
-
endReason: 'explicit_end',
|
|
1006
|
-
slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
|
|
1007
|
-
})
|
|
1008
|
-
writeSessionLog(entry)
|
|
1009
|
-
lastSnapshotCounts[session.id] = session.exchanges.length
|
|
1010
|
-
}
|
|
1011
|
-
}, intervalMs)
|
|
1032
|
+
snapshotTimer = setInterval(() => { runAutoSnapshotOnce() }, intervalMs)
|
|
1012
1033
|
console.log(`[conversation] Auto-snapshot started (every ${intervalMs / 1000}s)`)
|
|
1013
1034
|
}
|
|
1014
1035
|
|
|
1036
|
+
/** Tests only. */
|
|
1037
|
+
export function __resetAutoSnapshotForTests(): void {
|
|
1038
|
+
if (snapshotTimer) clearInterval(snapshotTimer)
|
|
1039
|
+
snapshotTimer = null
|
|
1040
|
+
lastSnapshotCounts = {}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1015
1043
|
/** Return all active sessions with exchanges, shaped for archiving */
|
|
1016
1044
|
export function getActiveSessions(): SessionToArchive[] {
|
|
1017
1045
|
const result: SessionToArchive[] = []
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log a rejected CORS origin (S1, 2026-11-09).
|
|
3
|
+
*
|
|
4
|
+
* The scan of 10/26 to 11/09 found 22 "Error: CORS blocked" lines with nothing else, so no one
|
|
5
|
+
* could tell a COS client bug from a probe. This logs the ORIGIN only (an Origin header is a
|
|
6
|
+
* scheme, host and port; never a header dump, cookie or token), once per origin per hour, with a
|
|
7
|
+
* running count, and caps how many distinct origins it remembers so a scanner cannot grow it.
|
|
8
|
+
*/
|
|
9
|
+
const ONE_HOUR_MS = 60 * 60_000
|
|
10
|
+
export const CORS_REJECT_ORIGIN_CAP = 200
|
|
11
|
+
const seen = new Map<string, { count: number; lastLoggedAt: number }>()
|
|
12
|
+
let overflow = 0
|
|
13
|
+
|
|
14
|
+
/** An origin made safe for one log line: printable ASCII, no spaces, at most 200 characters. */
|
|
15
|
+
export function sanitizeOrigin(origin: string): string {
|
|
16
|
+
const clean = origin.replace(/[^\x21-\x7e]/g, '?')
|
|
17
|
+
return clean.length > 200 ? `${clean.slice(0, 200)}…(${clean.length} chars)` : clean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type CorsRejectReason = 'not_allowed' | 'check_failed'
|
|
21
|
+
|
|
22
|
+
export function noteCorsRejection(
|
|
23
|
+
origin: string,
|
|
24
|
+
reason: CorsRejectReason,
|
|
25
|
+
now = Date.now(),
|
|
26
|
+
log: (line: string) => void = line => console.warn(line),
|
|
27
|
+
): boolean {
|
|
28
|
+
const key = sanitizeOrigin(origin)
|
|
29
|
+
let entry = seen.get(key)
|
|
30
|
+
if (!entry) {
|
|
31
|
+
if (seen.size >= CORS_REJECT_ORIGIN_CAP) {
|
|
32
|
+
overflow++
|
|
33
|
+
if (overflow === 1 || overflow % 100 === 0) {
|
|
34
|
+
log(`[cors] blocked ${overflow} request(s) from origins beyond the first ${CORS_REJECT_ORIGIN_CAP} distinct ones (not listed)`)
|
|
35
|
+
}
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
entry = { count: 0, lastLoggedAt: 0 }
|
|
39
|
+
seen.set(key, entry)
|
|
40
|
+
}
|
|
41
|
+
entry.count++
|
|
42
|
+
if (entry.lastLoggedAt && now - entry.lastLoggedAt < ONE_HOUR_MS) return false
|
|
43
|
+
entry.lastLoggedAt = now
|
|
44
|
+
log(`[cors] blocked origin=${key} reason=${reason} (${entry.count} from this origin since boot)`)
|
|
45
|
+
return true
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function __resetCorsRejectLogForTests(): void {
|
|
49
|
+
seen.clear()
|
|
50
|
+
overflow = 0
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|