@gotcos/glasses-server 6.24.4 → 6.25.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,43 @@
1
+ ## 6.25.0
2
+
3
+ Large video uploads: a 100 MiB cap, streamed to disk, compressed in the background.
4
+
5
+ - Video attachments may now be up to 100 MiB. Images and documents stay at 64 MiB, and
6
+ the kind is decided from the file's magic bytes rather than its declared Content-Type,
7
+ so a declared video type cannot buy the larger ceiling.
8
+ - Uploads no longer buffer in memory. The body streams into the existing staging
9
+ directory and moves into place through the hardened atomic rename, with the byte
10
+ ceiling enforced during the stream so an oversized body is refused about one chunk
11
+ past the limit instead of after landing in full. `GET /api/media/:id/content` is
12
+ streamed for the same reason — raising the cap had otherwise taken that route's peak
13
+ allocation from 64 MiB to 100 MiB per concurrent download.
14
+ - `requestTimeout` is now explicit at 900s on both listeners. Node's 300s default was
15
+ invisible at 64 MiB but would have destroyed a 100 MiB upload's socket roughly 140
16
+ seconds before the client's own deadline, breaking exactly the size band this enables.
17
+ 900s is the client's own ceiling, so the client always gives up first and can report a
18
+ real diagnostic instead of an opaque network error.
19
+ - Stored videos are compressed in the background with `libx265 -crf 30`, measured at
20
+ 2.7x smaller and SSIM 0.969 on a real 4K 30fps upload. Resolution and frame rate are
21
+ never reduced, because that is what later frame-by-frame review depends on and
22
+ upscaling cannot recover it. An encode that is not smaller, or that fails, or that
23
+ changes the geometry, leaves the original in place — there is no path where the only
24
+ copy is lost. Requires ffmpeg and ffprobe; without them the original is simply kept.
25
+ - `GET /api/health` publishes `mediaLimits`, so the phone no longer hardcodes a byte cap
26
+ that can drift from what this server will actually accept. Chunked upload is
27
+ advertised as unavailable because its endpoints are not mounted yet.
28
+
29
+ ## 6.24.5
30
+
31
+ COS Control provider proofs now isolate themselves from project customizations.
32
+
33
+ - Claude Code 2.1.227 began rejecting the automated readiness check in large COS
34
+ workspaces because it loaded project instructions, skills, plugins, and attachment
35
+ context before evaluating the tiny proof prompt. The request exceeded 200k tokens
36
+ and exited 1 even though Claude authentication and normal commands were healthy.
37
+ - The no-tool readiness subprocess now uses Claude Safe Mode. It still proves the
38
+ installed CLI, authentication, model access, process lifecycle, and exact response,
39
+ while avoiding unrelated workspace context. Normal glasses queries are unchanged.
40
+
1
41
  ## 6.24.4
2
42
 
3
43
  Rich-media attachments extend the established authenticated photo pipeline without
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.24.4",
3
+ "version": "6.25.0",
4
4
  "description": "COS Glasses \u2014 self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -192,6 +192,20 @@ app.use('/api', (req, res, next) => {
192
192
  })
193
193
 
194
194
  try {
195
+ // KNOWN HAZARD, deliberately not fixed here (2026-08-11). This lease is held for
196
+ // the WHOLE request, including the body transfer. Every other mutation is
197
+ // sub-second, but POST /api/media/file now accepts up to 100 MiB, which the
198
+ // client itself budgets ~7.3 minutes for — far past COS Control's 90s drain
199
+ // timeout (main.swift waitForRestartProof). A drain that catches a large upload
200
+ // in flight will therefore hard-fail to Repair.
201
+ //
202
+ // Why no fix in this change: there is no per-kind budget map to declare a longer
203
+ // allowance against, and blocksRestart belongs to the meeting-sync surface rather
204
+ // than a generic active-work registry, so a new 'media_upload' kind would be a
205
+ // label with no behaviour — a false signal that the case is handled. The correct
206
+ // fix is to scope this lease to the INGEST (fast: validate + index write) instead
207
+ // of the network transfer, which means changing a fail-closed middleware that
208
+ // guards every mutation. That needs its own pass and its own tests.
195
209
  const lease = acquireMaintenanceWork('api_mutation', {
196
210
  allowDuringDrain: controllerProof,
197
211
  })
@@ -333,20 +347,48 @@ process.on('unhandledRejection', (reason: any) => {
333
347
  // Start HTTPS alongside HTTP — Even Hub WebView prefers HTTPS (iOS ATS).
334
348
  // Optional: drop cert.pem + key.pem in server/certs/ (e.g. via mkcert) to enable.
335
349
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
350
+ /**
351
+ * Let a slow large upload finish instead of killing its socket mid-body.
352
+ *
353
+ * Node's default `requestTimeout` is 300s. That was invisible while the cap was
354
+ * 64 MiB, which needs ~262s at the throughput the CLIENT itself budgets for
355
+ * (UPLOAD_FLOOR_BYTES_PER_SEC = 250 KiB/s, cos-glasses-app shared/media-attachment.ts).
356
+ * Raising the video cap to 100 MiB pushes the worst case to ~7.3 minutes, so the default
357
+ * would have destroyed the socket roughly 140 seconds BEFORE the client's own deadline
358
+ * expired — breaking exactly the size band the raise exists to enable, and surfacing as
359
+ * an opaque network error instead of a timeout that states its budget.
360
+ *
361
+ * 900_000 is deliberately the client's UPLOAD_TIMEOUT_CEILING_MS, so the two repos agree
362
+ * by construction: the client always gives up first and gets to report the diagnostic.
363
+ *
364
+ * Wrapped at each createServer call rather than looped over `listeners`, because
365
+ * RequiredListener types `server` as the base net.Server, which has no requestTimeout.
366
+ * The generic constraint makes a server that cannot carry the timeout a compile error
367
+ * instead of a silent no-op or a cast that hides the HTTP-specific dependency.
368
+ *
369
+ * headersTimeout stays at its default — headers arrive immediately even on a slow body,
370
+ * so shortening their window is the wrong lever.
371
+ */
372
+ const MAX_REQUEST_MS = 900_000
373
+ function withRequestTimeout<T extends { requestTimeout: number }>(server: T): T {
374
+ server.requestTimeout = MAX_REQUEST_MS
375
+ return server
376
+ }
377
+
336
378
  const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
337
379
  const certDir = path.join(__dirname, 'certs')
338
380
  const listeners: RequiredListener[] = []
339
381
  if (existsSync(path.join(certDir, 'cert.pem'))) {
340
- const httpsServer = createHttpsServer({
382
+ const httpsServer = withRequestTimeout(createHttpsServer({
341
383
  cert: readFileSync(path.join(certDir, 'cert.pem')),
342
384
  key: readFileSync(path.join(certDir, 'key.pem')),
343
- }, app)
385
+ }, app))
344
386
  listeners.push({ server: httpsServer, port: HTTPS_PORT, host: BIND_HOST, label: 'HTTPS' })
345
387
  } else {
346
388
  console.log('[COS API] No certs found — HTTPS disabled (drop cert.pem/key.pem in server/certs to enable)')
347
389
  }
348
390
 
349
- const httpServer = createHttpServer(app)
391
+ const httpServer = withRequestTimeout(createHttpServer(app))
350
392
  listeners.push({ server: httpServer, port: PORT, host: BIND_HOST, label: 'HTTP' })
351
393
 
352
394
  listenRequiredServers(listeners).then(() => {
@@ -20,12 +20,14 @@
20
20
 
21
21
  import { createHash, randomBytes } from 'node:crypto'
22
22
  import {
23
+ createReadStream,
23
24
  existsSync,
24
25
  mkdirSync,
25
26
  readdirSync,
26
27
  readFileSync,
27
28
  renameSync,
28
29
  rmSync,
30
+ statSync,
29
31
  writeFileSync,
30
32
  } from 'node:fs'
31
33
  import { join, resolve, sep } from 'node:path'
@@ -51,7 +53,11 @@ import {
51
53
  sniffImageType,
52
54
  validateSourceImage,
53
55
  } from './image-safety.js'
54
- import { prepareRichMedia, type PreparedRichMedia } from './rich-media-safety.js'
56
+ import {
57
+ prepareRichMediaFromFile,
58
+ type PreparedRichMediaFile,
59
+ } from './rich-media-safety.js'
60
+ import { VIDEO_COMPRESSION_LABEL, compressVideoFile } from './video-compression.js'
55
61
 
56
62
  // Standalone state belongs under the same durable data root as conversations,
57
63
  // archives, and run ledgers. COS_MEDIA_ROOT remains an explicit escape hatch
@@ -108,6 +114,24 @@ async function renameWithTransientRetry(
108
114
  }
109
115
  }
110
116
 
117
+ /** The compressor's contract is enforced by the compiler, not mirrored here:
118
+ * this alias is what the constructor's test seam has to satisfy. */
119
+ export type CompressVideoFile = typeof compressVideoFile
120
+ export type { CompressionResult, CompressionStatus } from './video-compression.js'
121
+
122
+ /** Hash without holding the file in memory — a 100 MB video would otherwise
123
+ * reintroduce exactly the allocation the streaming upload path removed. */
124
+ async function sha256OfFile(path: string): Promise<string> {
125
+ const hash = createHash('sha256')
126
+ await new Promise<void>((resolveHash, rejectHash) => {
127
+ const stream = createReadStream(path)
128
+ stream.on('data', chunk => hash.update(chunk))
129
+ stream.once('error', rejectHash)
130
+ stream.once('end', () => resolveHash())
131
+ })
132
+ return hash.digest('hex')
133
+ }
134
+
111
135
  /** Test seam for the File Provider rename recovery contract. */
112
136
  export async function _renameWithTransientRetryForTests(
113
137
  source: string,
@@ -120,6 +144,17 @@ export async function _renameWithTransientRetryForTests(
120
144
 
121
145
  export type MediaLifecycle = 'staged' | 'reserved' | 'associated' | 'expired' | 'deleted'
122
146
 
147
+ export interface VideoCompressionRecord {
148
+ label: string
149
+ /** Byte count as uploaded, before the encode. */
150
+ originalBytes: number
151
+ /** Byte count now stored. Always smaller — the module returns
152
+ * `skipped_not_smaller` otherwise, and two measured settings really did
153
+ * produce files LARGER than the source. */
154
+ bytes: number
155
+ atMs: number
156
+ }
157
+
123
158
  export interface MediaRecord {
124
159
  ref: MediaAttachmentRef
125
160
  /** Relative to the media root. Never exposed through the API. */
@@ -131,6 +166,9 @@ export interface MediaRecord {
131
166
  bytes: number
132
167
  sha256: string
133
168
  lifecycle: MediaLifecycle
169
+ /** Set once the background x265 pass replaced the stored original with a
170
+ * smaller file. Kept so the size drop is explainable and never retried. */
171
+ videoCompression?: VideoCompressionRecord
134
172
  /** True once asset bytes were removed (content TTL or GC) while the
135
173
  * metadata record remains (e.g. expired traffic frames). */
136
174
  contentRemoved?: boolean
@@ -183,12 +221,42 @@ export interface IngestRichMediaInput {
183
221
  sessionId?: string
184
222
  }
185
223
 
224
+ /** A streamed upload that already landed in tmp/. Ingest MOVES this file into
225
+ * the asset directory, so the bytes are written exactly once. */
226
+ export interface IngestRichMediaFileInput {
227
+ sourcePath: string
228
+ byteLength: number
229
+ label?: string
230
+ declaredMime?: string
231
+ capturedAt?: string
232
+ sessionId?: string
233
+ }
234
+
235
+ /** Handle for a streaming upload's staging file. `dispose()` is idempotent and
236
+ * becomes a no-op once ingest has moved the file out. */
237
+ export interface MediaStagingFile {
238
+ path: string
239
+ dispose: () => void
240
+ }
241
+
186
242
  export type MediaContentResult =
187
243
  | { status: 'ok'; path: string; mime: MediaMime; bytes: number }
188
244
  | { status: 'not_found' }
189
245
  | { status: 'expired' }
190
246
  | { status: 'unavailable' }
191
247
 
248
+ /** Provenance only. A malformed value is dropped rather than invalidating the
249
+ * whole record — losing the note is survivable, losing the asset is not. */
250
+ function sanitizeVideoCompression(raw: unknown): VideoCompressionRecord | null {
251
+ if (!raw || typeof raw !== 'object') return null
252
+ const r = raw as Record<string, unknown>
253
+ const positiveInt = (value: unknown): value is number =>
254
+ typeof value === 'number' && Number.isSafeInteger(value) && value > 0
255
+ if (typeof r.label !== 'string' || r.label.length === 0 || r.label.length > 64) return null
256
+ if (!positiveInt(r.originalBytes) || !positiveInt(r.bytes) || !positiveInt(r.atMs)) return null
257
+ return { label: r.label.slice(0, 64), originalBytes: r.originalBytes, bytes: r.bytes, atMs: r.atMs }
258
+ }
259
+
192
260
  function sanitizeRecord(raw: unknown): MediaRecord | null {
193
261
  if (!raw || typeof raw !== 'object') return null
194
262
  const r = raw as Record<string, unknown>
@@ -208,6 +276,7 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
208
276
  const derivativePaths = Array.isArray(r.derivativePaths)
209
277
  ? r.derivativePaths.filter(isOwnedPath).slice(0, 8)
210
278
  : undefined
279
+ const videoCompression = sanitizeVideoCompression(r.videoCompression)
211
280
  return {
212
281
  ref,
213
282
  storagePath: r.storagePath,
@@ -217,6 +286,7 @@ function sanitizeRecord(raw: unknown): MediaRecord | null {
217
286
  bytes: typeof r.bytes === 'number' && r.bytes >= 0 ? r.bytes : 0,
218
287
  sha256: typeof r.sha256 === 'string' ? r.sha256 : '',
219
288
  lifecycle,
289
+ ...(videoCompression ? { videoCompression } : {}),
220
290
  contentRemoved: r.contentRemoved === true,
221
291
  ...(typeof r.sessionId === 'string' ? { sessionId: r.sessionId } : {}),
222
292
  ...(typeof r.clientQueueItemId === 'string' ? { clientQueueItemId: r.clientQueueItemId } : {}),
@@ -236,8 +306,11 @@ export class MediaStore {
236
306
  private readonly root: string
237
307
  private readonly records = new Map<string, MediaRecord>()
238
308
  private readonly renderLensVariant: typeof renderG2Variant
309
+ private readonly compressVideo: CompressVideoFile
239
310
  /** One cold-cache render per media id. Callers share the same promise. */
240
311
  private readonly g2InFlight = new Map<string, Promise<MediaContentResult>>()
312
+ /** Background x265 passes, keyed by media id. Never awaited by a request. */
313
+ private readonly compressionJobs = new Map<string, Promise<void>>()
241
314
  /** A corrupt/unreadable index makes the asset directory authoritative only
242
315
  * for recovery. Never classify its entries as disposable orphans that boot. */
243
316
  private allowOrphanCleanup = true
@@ -247,10 +320,16 @@ export class MediaStore {
247
320
 
248
321
  constructor(
249
322
  root: string = DEFAULT_MEDIA_ROOT,
250
- dependencies: { renderG2Variant?: typeof renderG2Variant } = {},
323
+ dependencies: {
324
+ renderG2Variant?: typeof renderG2Variant
325
+ /** Injected in tests so each compression outcome can be driven without
326
+ * running a real x265 encode. */
327
+ compressVideoFile?: CompressVideoFile
328
+ } = {},
251
329
  ) {
252
330
  this.root = root
253
331
  this.renderLensVariant = dependencies.renderG2Variant ?? renderG2Variant
332
+ this.compressVideo = dependencies.compressVideoFile ?? compressVideoFile
254
333
  this.ensureDirs()
255
334
  this.loadIndex()
256
335
  this.reconcile()
@@ -417,19 +496,57 @@ export class MediaStore {
417
496
  return this.publishNormalizedImage(input, normalized)
418
497
  }
419
498
 
420
- /** Authenticated user document/video ingress. Validation and derivative
421
- * generation happen before the serialized index publication. */
422
- async ingestRichMedia(input: IngestRichMediaInput): Promise<MediaAttachmentRef> {
423
- const prepared = await prepareRichMedia(input.bytes, {
499
+ /** Reserve a private staging file for a streaming upload. Uploads have always
500
+ * staged in tmp/ before the atomic move into assets/; a streamed body uses
501
+ * the same convention rather than inventing a second one. */
502
+ createStagingFile(): MediaStagingFile {
503
+ // tmp/ is emptied by boot reconcile, so recreate defensively rather than
504
+ // trusting a directory that existed at construction time.
505
+ this.ensureDirs()
506
+ const path = join(this.root, 'tmp', `upload-${randomBytes(12).toString('hex')}.bin`)
507
+ return {
508
+ path,
509
+ dispose: () => {
510
+ try { rmSync(path, { force: true }) } catch { /* already moved or gone */ }
511
+ },
512
+ }
513
+ }
514
+
515
+ /** Authenticated user document/video ingress from a streamed staging file.
516
+ * Validation and derivative generation happen before the serialized index
517
+ * publication; the staged file is MOVED into the asset dir, never copied. */
518
+ async ingestRichMediaFromFile(input: IngestRichMediaFileInput): Promise<MediaAttachmentRef> {
519
+ const prepared = await prepareRichMediaFromFile(input.sourcePath, {
424
520
  label: input.label,
425
521
  declaredMime: input.declaredMime,
522
+ byteLength: input.byteLength,
426
523
  })
427
524
  return this.publishPreparedRichMedia(input, prepared)
428
525
  }
429
526
 
527
+ /** In-memory ingress for callers that already hold the bytes. Stages them in
528
+ * tmp/ and joins the single file-based path above — a second publish path
529
+ * would be a second place for the invariants to rot. */
530
+ async ingestRichMedia(input: IngestRichMediaInput): Promise<MediaAttachmentRef> {
531
+ const staged = this.createStagingFile()
532
+ try {
533
+ writeFileSync(staged.path, input.bytes, { mode: 0o600 })
534
+ return await this.ingestRichMediaFromFile({
535
+ sourcePath: staged.path,
536
+ byteLength: input.bytes.length,
537
+ label: input.label,
538
+ declaredMime: input.declaredMime,
539
+ capturedAt: input.capturedAt,
540
+ sessionId: input.sessionId,
541
+ })
542
+ } finally {
543
+ staged.dispose()
544
+ }
545
+ }
546
+
430
547
  private async publishPreparedRichMedia(
431
- input: IngestRichMediaInput,
432
- prepared: PreparedRichMedia,
548
+ input: { label?: string; capturedAt?: string; sessionId?: string },
549
+ prepared: PreparedRichMediaFile,
433
550
  ): Promise<MediaAttachmentRef> {
434
551
  const id = `m_${randomBytes(12).toString('hex')}`
435
552
  const now = Date.now()
@@ -449,7 +566,7 @@ export class MediaStore {
449
566
  width: prepared.category === 'video' ? prepared.width : 1,
450
567
  height: prepared.category === 'video' ? prepared.height : 1,
451
568
  createdAt: nowIso,
452
- bytes: prepared.original.length,
569
+ bytes: prepared.originalBytes,
453
570
  ...(prepared.category === 'video'
454
571
  ? { durationMs: prepared.durationMs, frameCount: prepared.frames.length }
455
572
  : {
@@ -464,8 +581,13 @@ export class MediaStore {
464
581
  mkdirSync(stageDir, { recursive: true, mode: 0o700 })
465
582
  const originalName = `original.${extension}`
466
583
  const derivativeNames: string[] = []
584
+ let sha256: string
467
585
  try {
468
- writeFileSync(join(stageDir, originalName), prepared.original, { mode: 0o600 })
586
+ // Hash by streaming, then MOVE the staged upload in. Both live under
587
+ // tmp/, so this is a same-volume rename — the alternative is a second
588
+ // full write of a body that can be 100 MB.
589
+ sha256 = await sha256OfFile(prepared.originalPath)
590
+ await renameWithTransientRetry(prepared.originalPath, join(stageDir, originalName))
469
591
  if (prepared.category === 'document') {
470
592
  writeFileSync(join(stageDir, 'content.txt'), prepared.extractedText, { mode: 0o600 })
471
593
  }
@@ -487,8 +609,8 @@ export class MediaStore {
487
609
  thumbPath: derivativePaths[0] ?? storagePath,
488
610
  ...(prepared.category === 'document' ? { textPath: join('assets', id, 'content.txt') } : {}),
489
611
  ...(derivativePaths.length ? { derivativePaths } : {}),
490
- bytes: prepared.original.length,
491
- sha256: createHash('sha256').update(prepared.original).digest('hex'),
612
+ bytes: prepared.originalBytes,
613
+ sha256,
492
614
  lifecycle: 'staged',
493
615
  ...(input.sessionId ? { sessionId: input.sessionId } : {}),
494
616
  createdAtMs: now,
@@ -498,9 +620,107 @@ export class MediaStore {
498
620
  this.records.set(id, record)
499
621
  this.saveIndex()
500
622
  })
623
+ // AFTER publication and outside the lock: x265 encodes at roughly real
624
+ // time, so awaiting it here would hold the upload response open for the
625
+ // length of the video.
626
+ if (prepared.category === 'video') this.scheduleVideoCompression(id)
501
627
  return ref
502
628
  }
503
629
 
630
+ // ── Background video compression ───────────────────────────────────────────
631
+
632
+ private scheduleVideoCompression(id: string): void {
633
+ const job = (async () => {
634
+ try {
635
+ await this.compressVideoAsset(id)
636
+ } catch (err) {
637
+ // A failed encode is a non-event: the original is still the asset.
638
+ console.error(`[media-store] video compression failed for ${id}:`, err)
639
+ }
640
+ })()
641
+ this.compressionJobs.set(id, job)
642
+ void job.then(() => {
643
+ if (this.compressionJobs.get(id) === job) this.compressionJobs.delete(id)
644
+ })
645
+ }
646
+
647
+ /** Encode a published video smaller in place. Every exit other than a
648
+ * validated smaller output leaves the original untouched, and the swap is a
649
+ * rename, so there is no window in which the only copy is missing. */
650
+ private async compressVideoAsset(id: string): Promise<void> {
651
+ const before = this.getRecord(id)
652
+ if (!before || before.videoCompression) return
653
+ if (mediaCategoryOf(before.ref) !== 'video') return
654
+ if (before.lifecycle === 'deleted' || before.lifecycle === 'expired' || before.contentRemoved) return
655
+ const compress = this.compressVideo
656
+ const inputPath = this.absPath(before.storagePath)
657
+ if (!existsSync(inputPath)) return
658
+
659
+ // workDir sits under the media root so the output rename into assets/ is a
660
+ // same-volume atomic replace; a cross-device rename fails EXDEV.
661
+ const workDir = join(this.root, 'tmp', `compress-${id}-${randomBytes(6).toString('hex')}`)
662
+ mkdirSync(workDir, { recursive: true, mode: 0o700 })
663
+ try {
664
+ const result = await compress(inputPath, workDir)
665
+ if (result.status !== 'compressed' || !result.outputPath) {
666
+ console.log(
667
+ `[media-store] video compression ${result.status} for ${id}` +
668
+ `${result.reason ? ` (${result.reason})` : ''}`,
669
+ )
670
+ return
671
+ }
672
+ if (!existsSync(result.outputPath)) {
673
+ console.error(`[media-store] video compression reported 'compressed' for ${id} with no output file`)
674
+ return
675
+ }
676
+ const compressedBytes = statSync(result.outputPath).size
677
+ // Trust-but-verify the module's own contract. An encode that is not
678
+ // smaller is a failure, not a result — two measured settings inflated
679
+ // the file — and swapping one in would cost storage for nothing.
680
+ if (compressedBytes <= 0 || compressedBytes >= before.bytes) {
681
+ console.warn(
682
+ `[media-store] discarding compression output for ${id}: ` +
683
+ `${compressedBytes} bytes vs original ${before.bytes}`,
684
+ )
685
+ return
686
+ }
687
+ const sha256 = await sha256OfFile(result.outputPath)
688
+ const outputPath = result.outputPath
689
+ await this.withLock(async () => {
690
+ // Compression is asynchronous, so a delete/release/GC may have landed
691
+ // while it ran. Re-check before publishing so removed bytes are never
692
+ // resurrected.
693
+ const rec = this.records.get(id)
694
+ if (!rec || rec.lifecycle === 'deleted' || rec.lifecycle === 'expired' || rec.contentRemoved) return
695
+ if (rec.storagePath !== before.storagePath || !existsSync(inputPath)) return
696
+ await renameWithTransientRetry(outputPath, inputPath)
697
+ rec.videoCompression = {
698
+ label: VIDEO_COMPRESSION_LABEL,
699
+ originalBytes: rec.bytes,
700
+ bytes: compressedBytes,
701
+ atMs: Date.now(),
702
+ }
703
+ rec.bytes = compressedBytes
704
+ rec.sha256 = sha256
705
+ // Keep the public size honest about what is actually stored.
706
+ rec.ref = { ...rec.ref, bytes: compressedBytes }
707
+ rec.updatedAtMs = Date.now()
708
+ this.saveIndex()
709
+ })
710
+ } finally {
711
+ try { rmSync(workDir, { recursive: true, force: true }) } catch { /* best effort */ }
712
+ }
713
+ }
714
+
715
+ /** Test seam: background compression is deliberately not awaited by ingest,
716
+ * so tests need a handle to settle it. */
717
+ async _awaitVideoCompressionForTests(id?: string): Promise<void> {
718
+ const jobs = id
719
+ ? [this.compressionJobs.get(id)].filter((job): job is Promise<void> => job != null)
720
+ : [...this.compressionJobs.values()]
721
+ await Promise.all(jobs)
722
+ }
723
+
504
724
  /** Trusted-local agent artifact ingress. Unlike the public upload path,
505
725
  * this accepts a bounded larger JPEG/PNG/WebP/HEIC/AVIF and immediately
506
726
  * converts it into the same normalized JPEG contract before publication. */
@@ -25,6 +25,12 @@ export interface ProcessResult {
25
25
  aborted: boolean
26
26
  }
27
27
 
28
+ // A readiness proof must validate the installed/authenticated provider, not load
29
+ // the selected workspace's CLAUDE.md, skills, plugins, MCP servers, and other
30
+ // project customizations. Large COS workspaces can otherwise overflow Claude's
31
+ // context window before the tiny proof prompt is evaluated.
32
+ export const CLAUDE_SAFE_MODE_ENV = 'CLAUDE_CODE_SAFE_MODE'
33
+
28
34
  type TerminationReason = 'timeout' | 'abort' | null
29
35
 
30
36
  export function runBounded(
@@ -41,6 +47,7 @@ export function runBounded(
41
47
  }
42
48
  const env = { ...process.env }
43
49
  delete env.CLAUDECODE
50
+ env[CLAUDE_SAFE_MODE_ENV] = '1'
44
51
  const child = spawn(command, args, {
45
52
  cwd: cosBrainDir() ?? process.cwd(),
46
53
  env,